Haskell-数据分析秘籍-全-
Haskell 数据分析秘籍(全)
原文:
annas-archive.org/md5/3ff53e35b37e2f50c639bfc6fc052f29
译者:飞龙
前言
数据分析是我们许多人之前可能甚至不知道自己已经做过的事情。这是收集和分析信息的基本艺术,以适应各种目的——从视觉检查到机器学习技术。通过数据分析,我们可以从数字领域四处散布的信息中获取意义。它使我们能够解决最奇特的问题,甚至在此过程中提出新问题。
Haskell 作为我们进行强大数据分析的桥梁。对于一些人来说,Haskell 是一种保留给学术界和工业界最精英研究人员的编程语言。然而,我们看到它正吸引着全球开源开发者中最快增长的文化之一。Haskell 的增长表明,人们正在发现其优美的函数式纯净性、强大的类型安全性和卓越的表达力。翻开本书的页面,看到这一切的实际运用。
Haskell 数据分析烹饪书不仅仅是计算机领域两个迷人主题的融合。它还是 Haskell 编程语言的学习工具,以及简单数据分析实践的介绍。将其视为算法和代码片段的瑞士军刀。尝试每天一个配方,就像大脑的武术训练。从催化的示例中轻松翻阅本书,获得创意灵感。最重要的是,深入探索 Haskell 中的数据分析领域。
当然,如果没有 Lonku(lonku.tumblr.com
)提供的精彩章节插图和 Packt Publishing 提供的有益布局和编辑支持,这一切都是不可能的。
本书内容涵盖
第一章, 数据的探寻,识别了从各种外部来源(如 CSV、JSON、XML、HTML、MongoDB 和 SQLite)读取数据的核心方法。
第二章, 完整性与检验,解释了通过关于修剪空白、词法分析和正则表达式匹配的配方清理数据的重要性。
第三章, 单词的科学,介绍了常见的字符串操作算法,包括基数转换、子串匹配和计算编辑距离。
第四章, 数据哈希,涵盖了诸如 MD5、SHA256、GeoHashing 和感知哈希等重要的哈希函数。
第五章, 树的舞蹈,通过包括树遍历、平衡树和 Huffman 编码等示例,建立对树数据结构的理解。
第六章, 图基础,展示了用于图网络的基础算法,如图遍历、可视化和最大团检测。
第七章,统计与分析,开始了对重要数据分析技术的探索,其中包括回归算法、贝叶斯网络和神经网络。
第八章,聚类与分类,涉及典型的分析方法,包括 k-means 聚类、层次聚类、构建决策树以及实现 k 最近邻分类器。
第九章,并行与并发设计,介绍了 Haskell 中的高级主题,如分叉 I/O 操作、并行映射列表和性能基准测试。
第十章,实时数据,包含来自 Twitter、Internet Relay Chat(IRC)和套接字的流式数据交互。
第十一章,可视化数据,涉及多种绘制图表的方法,包括折线图、条形图、散点图和 D3.js
可视化。
第十二章,导出与展示,以一系列将数据导出为 CSV、JSON、HTML、MongoDB 和 SQLite 的算法结束本书。
本书所需内容
-
首先,您需要一个支持 Haskell 平台的操作系统,如 Linux、Windows 或 Mac OS X。
-
您必须安装 Glasgow Haskell Compiler 7.6 或更高版本及 Cabal,这两者都可以从
www.haskell.org/platform
获取。 -
您可以在 GitHub 上获取每个食谱的配套源代码,网址为
github.com/BinRoot/Haskell-Data-Analysis-Cookbook
。
本书适合谁阅读
-
对那些已经开始尝试使用 Haskell 并希望通过有趣的示例来启动新项目的人来说,这本书是不可或缺的。
-
对于刚接触 Haskell 的数据分析师,本书可作为数据建模问题的函数式方法参考。
-
对于初学 Haskell 语言和数据分析的读者,本书提供了最大的学习潜力,可以帮助您掌握书中涉及的新话题。
约定
在本书中,您将看到多种文本样式,用以区分不同类型的信息。以下是一些这些样式的示例,并附有其含义的解释。
文本中的代码词、数据库表名、文件夹名称、文件名、文件扩展名、路径名、虚拟网址、用户输入和 Twitter 用户名将如下所示:“将 readString
函数应用于输入,并获取所有日期文档。”
一块代码块如下所示:
main :: IO ()
main = do
input <- readFile "input.txt"
print input
当我们希望引起您对代码块中特定部分的注意时,相关的行或项目将以粗体显示:
main :: IO ()
main = do
input <- readFile "input.txt"
print input
任何命令行输入或输出都将如下所示:
$ runhaskell Main.hs
新术语和重要单词以粗体显示。你在屏幕上、菜单或对话框中看到的词语,通常以这种形式出现在文本中:“在下载部分,下载 cabal 源代码包。”
注意
警告或重要的注意事项以框框的形式呈现。
提示
提示和技巧以这种形式出现。
读者反馈
我们始终欢迎来自读者的反馈。让我们知道你对这本书的看法——你喜欢什么,或者可能不喜欢什么。读者反馈对我们开发能够让你真正受益的书籍至关重要。
要向我们发送一般反馈,只需发送电子邮件至<feedback@packtpub.com>
,并通过邮件主题注明书籍名称。
如果你在某个领域有专业知识,并且有兴趣撰写或参与撰写一本书,查看我们的作者指南:www.packtpub.com/authors。
客户支持
现在你已经是一本 Packt 图书的骄傲拥有者,我们为你提供了一些帮助,以便你能够最大限度地从你的购买中受益。
下载示例代码
你可以从你在www.packtpub.com
的账户中下载你购买的所有 Packt 图书的示例代码文件。如果你是从其他地方购买的这本书,可以访问www.packtpub.com/support
并注册,将文件直接通过电子邮件发送给你。此外,我们强烈建议你从 GitHub 获取所有源代码,网址为github.com/BinRoot/Haskell-Data-Analysis-Cookbook
。
勘误表
尽管我们已尽一切努力确保内容的准确性,但错误难免发生。如果你在我们的书籍中发现错误——可能是文本错误或代码错误——我们将非常感激你报告给我们。通过这样做,你可以帮助其他读者避免困扰,并帮助我们改进后续版本的书籍。如果你发现任何勘误,请通过访问www.packtpub.com/submit-errata
,选择你的书籍,点击勘误提交表单链接,并输入勘误的详细信息。一旦你的勘误被验证,提交将被接受,并且勘误将被上传到我们的网站,或添加到该书勘误列表中。任何现有的勘误都可以通过选择你书籍标题,访问www.packtpub.com/support
查看。代码修订也可以在附带的 GitHub 仓库进行修改,仓库地址为github.com/BinRoot/Haskell-Data-Analysis-Cookbook
。
盗版
互联网版权材料的盗版问题在各类媒体中普遍存在。我们在 Packt 非常重视版权和许可的保护。如果您在互联网遇到我们作品的任何非法复制,无论其形式如何,请立即向我们提供该位置地址或网站名称,以便我们采取补救措施。
请通过 <copyright@packtpub.com>
与我们联系,并提供涉嫌盗版材料的链接。
我们感谢您的帮助,以保护我们的作者,以及我们为您提供有价值内容的能力。
问题
如果您在书籍的任何方面遇到问题,可以通过 <questions@packtpub.com>
联系我们,我们将尽力解决。
第一章 数据的猎寻
本章将涵盖以下食谱:
-
利用来自不同来源的数据
-
从文件路径积累文本数据
-
捕捉 I/O 代码故障
-
保存和表示来自 CSV 文件的数据
-
使用 aeson 包检查 JSON 文件
-
使用 HXT 包读取 XML 文件
-
捕获 HTML 页面中的表格行
-
理解如何执行 HTTP GET 请求
-
学习如何执行 HTTP POST 请求
-
遍历在线目录以获取数据
-
在 Haskell 中使用 MongoDB 查询
-
从远程 MongoDB 服务器读取数据
-
探索来自 SQLite 数据库的数据
介绍
数据无处不在,日志记录便宜,分析是不可避免的。本章的一个最基本的概念就是收集有用的数据。在建立了一个大规模的可用文本集合后,我们称之为语料库,我们必须学会在代码中表示这些内容。主要关注将首先是获取数据,随后是列举表示数据的各种方式。
收集数据在某种程度上与分析数据一样重要,以便推断结果并形成有效的普遍性结论。这是一个科学的追求;因此,必须且将会非常小心地确保采样无偏且具有代表性。我们建议在本章中密切跟随,因为本书的其余部分都依赖于有数据源可供操作。如果没有数据,就几乎没有什么可以分析的,所以我们应该仔细观察在本章中提出的技术,以便构建我们自己的强大语料库。
第一条食谱列举了多种在线收集数据的来源。接下来的几个食谱涉及使用不同文件格式的本地数据。然后我们学习如何使用 Haskell 代码从互联网上下载数据。最后,我们以几个使用 Haskell 数据库的食谱结束本章。
利用来自不同来源的数据
信息可以被描述为结构化、非结构化,或有时是两者的混合——半结构化。
从广义上讲,结构化数据是指任何可以被算法解析的数据。常见的例子包括 JSON、CSV 和 XML。如果提供了结构化数据,我们可以设计一段代码来分析其底层格式,并轻松地生成有用的结果。由于挖掘结构化数据是一个确定性的过程,这使得我们可以自动化解析,从而让我们收集更多的输入来喂养我们的数据分析算法。
非结构化数据是指其他所有的数据。它是没有按照特定方式定义的数据。像英语这样的书面语言通常被视为非结构化数据,因为从自然句子中解析出数据模型非常困难。
在寻找好数据的过程中,我们常常会发现结构化和非结构化文本的混合。这就是所谓的半结构化文本。
本食谱将主要关注从以下来源获取结构化和半结构化数据。
提示
与本书中的大多数食谱不同,这个食谱不包含任何代码。阅读本书的最佳方式是跳到那些你感兴趣的食谱。
如何实现…
我们将通过以下章节提供的链接浏览,以建立一个源列表,利用可用格式的有趣数据。然而,这个列表并不详尽。
其中一些数据源提供应用程序编程接口(API),允许更复杂地访问有趣的数据。API 指定了交互方式并定义了数据如何传输。
新闻
《纽约时报》拥有最精炼的 API 文档之一,能够访问从房地产数据到文章搜索结果的各种内容。该文档可以在developer.nytimes.com
找到。
《卫报》还提供了一个包含超过一百万篇文章的大型数据存储库,网址为www.theguardian.com/data
。
《今日美国》提供有关书籍、电影和音乐评论的一些有趣资源。技术文档可以在developer.usatoday.com
找到。
BBC 提供一些有趣的 API 端点,包括 BBC 节目和音乐信息,网址为www.bbc.co.uk/developer/technology/apis.html
。
私人
Facebook、Twitter、Instagram、Foursquare、Tumblr、SoundCloud、Meetup 等许多社交网络网站支持 API 来访问一定程度的社交信息。
对于特定的 API,如天气或体育,Mashape 是一个集中式搜索引擎,可以缩小搜索范围到一些较不为人知的来源。Mashape 的网址是www.mashape.com/
大多数数据源可以通过位于www.google.com/publicdata
的 Google 公共数据搜索进行可视化。
要查看包含各种数据格式的所有国家列表,请参考位于github.com/umpirsky/country-list
的代码库。
学术
一些数据源由世界各地的大学公开托管,用于研究目的。
为了分析医疗数据,华盛顿大学已发布健康指标与评估研究所(IHME),以收集世界上最重要的健康问题的严格且可比较的测量数据。更多信息请访问www.healthdata.org
。
来自纽约大学、谷歌实验室和微软研究院的 MNIST 手写数字数据库,是一个用于手写数字的标准化和居中样本的训练集。可以从yann.lecun.com/exdb/mnist
下载数据。
非营利组织
《人类发展报告》每年更新,涵盖从成人识字率到拥有个人电脑人数的国际数据。它自称拥有多种国际公共资源,并代表了这些指标的最新统计数据。更多信息请访问hdr.undp.org/en/statistics
。
世界银行是贫困和全球发展数据的来源。它自认为是一个自由来源,旨在提供全球各国发展的开放数据访问。更多信息请访问data.worldbank.org/
。
世界卫生组织提供全球健康状况监测的数据和分析。更多信息请访问www.who.int/research/en
。
联合国儿童基金会(UNICEF)还发布了有趣的统计数据,正如其网站上的引用所示:
“联合国儿童基金会数据库包含儿童死亡率、疾病、水卫生等方面的统计表。联合国儿童基金会声称在监测儿童和妇女状况方面发挥着核心作用——帮助各国收集和分析数据,协助他们制定方法论和指标,维护全球数据库,传播和发布数据。可以在
www.unicef.org/statistics
找到相关资源。”
联合国在www.un.org/en/databases
发布有趣的公开政治统计数据。
美国政府
如果我们像尼古拉斯·凯奇在电影《国家宝藏》(2004 年)中所做的那样,渴望发现美国政府中的模式,那么www.data.gov/
将是我们的首选来源。它是美国政府积极提供有用数据的努力,旨在“增加公众对联邦政府执行部门生成的高价值、机器可读数据集的访问。”更多信息请访问www.data.gov
。
美国人口普查局发布人口统计、住房统计、区域测量等数据。这些数据可以在www.census.gov
找到。
从文件路径累积文本数据
开始处理输入的最简单方法之一是从本地文件读取原始文本。在这个例子中,我们将从特定的文件路径提取所有文本。此外,为了对数据做些有趣的事情,我们将统计每行的单词数。
提示
Haskell 是一种纯粹的函数式编程语言,对吗?没错,但从代码外部获取输入会引入不纯净性。为了优雅性和可重用性,我们必须仔细区分纯净代码和不纯净代码。
准备开始
我们首先创建一个input.txt
文本文件,文件中有几行文本供程序读取。我们将此文件保存在一个容易访问的目录中,因为稍后会用到。比如,我们正在处理的文本文件包含了一段柏拉图的七行引用。以下是我们执行以下命令时终端的输出:
$ cat input.txt
And how will you inquire, Socrates,
into that which you know not?
What will you put forth as the subject of inquiry?
And if you find what you want,
how will you ever know that
this is what you did not know?
小贴士
下载示例代码
你可以从你的账户中下载所有购买的 Packt 书籍的示例代码文件,网址是www.packtpub.com
。如果你是在其他地方购买了本书,你可以访问www.packtpub.com/support
并注册以便直接将文件通过电子邮件发送给你。代码也将托管在 GitHub 上,网址是github.com/BinRoot/Haskell-Data-Analysis-Cookbook
。
如何操作...
创建一个新文件开始编写代码。我们将文件命名为 Main.hs。
-
与所有可执行的 Haskell 程序一样,首先定义并实现
main
函数,如下所示:main :: IO () main = do
-
使用 Haskell 的
readFile :: FilePath -> IO String
函数来从input.txt
文件路径中提取数据。请注意,文件路径实际上只是String
的同义词。将字符串加载到内存后,将其传递给countWords
函数,以便计算每行的单词数,如下所示:input <- readFile "input.txt" print $ countWords input
-
最后,定义我们的纯函数
countWords
,如下所示:countWords :: String -> [Int] countWords input = map (length.words) (lines input)
-
程序将打印出每行的单词数,并以数字列表的形式呈现,具体如下:
$ runhaskell Main.hs [6,6,10,7,6,7]
它是如何工作的...
Haskell 提供了有用的输入和输出(I/O)功能,可以以不同方式读取输入和写入输出。在我们的例子中,我们使用readFile
来指定要读取的文件路径。使用main
中的do
关键字意味着我们将多个 I/O 操作连接在一起。readFile
的输出是一个 I/O 字符串,这意味着它是一个返回String
类型的 I/O 操作。
现在我们要进入一些技术细节,请注意。或者,你可以微笑并点头表示理解。在 Haskell 中,I/O 数据类型是名为 Monad 的实例。这允许我们使用<-
符号从这个 I/O 操作中提取字符串。然后,我们通过将字符串传递给countWords
函数来使用它,从而计算每行的单词数。请注意,我们将countWords
函数与不纯粹的main
函数分开。
最后,我们打印出countWords
的输出。$
符号表示我们使用函数应用来避免在代码中使用过多的括号。如果没有它,main
的最后一行将是print (countWords input)
。
另见
为了简便起见,这段代码易于阅读,但非常脆弱。如果input.txt
文件不存在,运行代码将立即使程序崩溃。例如,以下命令将生成错误信息:
$ runhaskell Main.hs
Main.hs: input.txt: openFile: does not exist…
为了使这段代码具有容错性,请参考 捕获 I/O 代码错误 的做法。
捕获 I/O 代码错误
确保我们的代码在数据挖掘或分析过程中不会崩溃是一个非常重要的考虑因素。某些计算可能需要几个小时,甚至几天。Haskell 提供了类型安全和强类型检查,以帮助确保程序不会失败,但我们也必须小心,仔细检查可能发生故障的边缘情况。
例如,如果没有找到本地文件路径,程序可能会异常崩溃。在前面的例子中,我们的代码强烈依赖于 input.txt
的存在。如果程序无法找到该文件,它将产生以下错误:
mycode: input.txt: openFile: does not exist (No such file or directory)
自然地,我们应该通过允许用户指定文件路径以及在文件未找到时不让程序崩溃,从而解耦文件路径的依赖关系。
考虑对源代码进行以下修改。
如何做到……
创建一个新文件,命名为 Main.hs
,并执行以下步骤:
-
首先,导入一个库来捕获致命错误,如下所示:
import Control.Exception (catch, SomeException)
-
接下来,导入一个库来获取命令行参数,使文件路径动态化。我们使用以下代码行来实现:
import System.Environment (getArgs)
-
按照之前的方式,定义并实现
main
如下:main :: IO () main = do
-
根据用户提供的参数定义一个
fileName
字符串,如果没有参数则默认为input.txt
。该参数通过从库函数getArgs :: IO [String]
中获取字符串数组来获取,如以下步骤所示:args <- getArgs let filename = case args of (a:_) -> a _ -> "input.txt"
-
现在在这个路径上应用
readFile
,但使用库的catch :: Exception e => IO a -> (e -> IO a) -> IO a
函数来捕获任何错误。catch
的第一个参数是要运行的计算,第二个参数是如果出现异常时要调用的处理程序,如以下命令所示:input <- catch (readFile fileName) $ \err -> print (err::SomeException) >> return ""
-
如果读取文件时出现错误,
input
字符串将为空。我们现在可以使用input
来执行任何操作,如下所示:print $ countWords input
-
别忘了定义
countWords
函数,如下所示:countWords input = map (length.words) (lines input)
它是如何工作的……
这个例子展示了两种捕获错误的方法,如下所示:
-
首先,我们使用一个模式匹配的
case
表达式来匹配传入的任何参数。因此,如果没有传入参数,args
列表为空,最后的模式"_"
会被捕获,从而得到默认的文件名input.txt
。 -
其次,我们使用
catch
函数来处理错误,如果出现问题。在读取文件时遇到麻烦时,我们通过将input
设置为空字符串来允许代码继续运行。
还有更多……
方便的是,Haskell 还提供了一个来自 System.Directory
模块的 doesFileExist :: FilePath -> IO Bool
函数。我们可以通过修改 input <- …
这一行来简化之前的代码。它可以被以下代码片段替换:
exists <- doesFileExist filename
input <- if exists then readFile filename else return ""
在这种情况下,代码只有在文件存在时才会将其作为输入读取。不要忘记在源代码的顶部添加以下 import
语句:
import System.Directory (doesFileExist)
保留和表示来自 CSV 文件的数据
逗号分隔值(CSV)是一种以纯文本表示数值表格的格式。它通常用于与电子表格中的数据进行交互。CSV 的规格在 RFC 4180 中有描述,可以在tools.ietf.org/html/rfc4180
找到。
在这个例子中,我们将读取一个名为input.csv
的本地 CSV 文件,里面包含各种姓名及其对应的年龄。然后,为了对数据做一些有意义的操作,我们将找到最年长的人。
准备工作
准备一个简单的 CSV 文件,列出姓名及其对应的年龄。可以使用文本编辑器完成此操作,或通过电子表格导出,如下图所示:
原始的input.csv
文件包含以下文本:
$ cat input.csv
name,age
Alex,22
Anish,22
Becca,23
Jasdev,22
John,21
Jonathon,21
Kelvin,22
Marisa,19
Shiv,22
Vinay,22
该代码还依赖于csv
库。我们可以使用以下命令通过 Cabal 安装该库:
$ cabal install csv
如何操作...
-
使用以下代码行导入
csv
库:import Text.CSV
-
定义并实现
main
,在这里我们将读取并解析 CSV 文件,如以下代码所示:main :: IO () main = do let fileName = "input.csv" input <- readFile fileName
-
将
parseCSV
应用于文件名,以获得一系列行,表示表格数据。parseCSV
的输出是Either ParseError CSV
,因此确保我们考虑Left
和Right
两种情况:let csv = parseCSV fileName input either handleError doWork csv handleError csv = putStrLn "error parsing" doWork csv = (print.findOldest.tail) csv
-
现在我们可以处理 CSV 数据了。在这个例子中,我们找到并打印包含最年长的人的行,如下面的代码片段所示:
findOldest :: [Record] -> Record findOldest [] = [] findOldest xs = foldl1 (\a x -> if age x > age a then x else a) xs age [a,b] = toInt a toInt :: String -> Int toInt = read
-
运行
main
后,代码应该产生以下输出:$ runhaskell Main.hs ["Becca", "23"]
提示
我们也可以使用
parseCSVFromFile
函数直接从文件名获取 CSV 表示,而不是使用readFile
后接parseCSV
。
如何操作...
在 Haskell 中,CSV 数据结构表示为一个记录列表。Record
仅仅是Fields
的列表,Field
是String
的类型别名。换句话说,它是表示表格的行的集合,如下图所示:
parseCSV
库函数返回一个Either
类型,Left
侧是一个ParseError
,Right
侧是一个列表的列表。Either l r
数据类型与Maybe a
类型非常相似,后者有Just a
或Nothing
构造器。
我们使用either
函数来处理Left
和Right
的情况。Left
情况处理错误,Right
情况处理数据上的实际操作。在这个例子中,Right
侧是一个Record
。Record
中的字段可以通过任何列表操作进行访问,例如head
、last
、!!
等。
使用 aeson 包检查 JSON 文件
JavaScript 对象表示法(JSON)是一种以纯文本表示键值对的方式。该格式在 RFC 4627 中有广泛描述(www.ietf.org/rfc/rfc4627
)。
在这个例子中,我们将解析一个关于某人的 JSON 描述。我们常在来自 Web 应用程序的 API 中遇到 JSON 格式。
准备工作
使用 Cabal 从 hackage 安装aeson
库。
准备一个代表数学家的input.json
文件,如下代码片段所示:
$ cat input.json
{"name":"Gauss", "nationality":"German", "born":1777, "died":1855}
我们将解析这个 JSON 并将其表示为 Haskell 中的可用数据类型。
如何操作...
-
使用
OverloadedStrings
语言扩展将字符串表示为ByteString
,如下代码行所示:{-# LANGUAGE OverloadedStrings #-}
-
如下所示导入
aeson
及一些辅助函数:import Data.Aeson import Control.Applicative import qualified Data.ByteString.Lazy as B
-
创建与 JSON 结构对应的数据类型,如下代码所示:
data Mathematician = Mathematician { name :: String , nationality :: String , born :: Int , died :: Maybe Int }
-
如下代码片段所示,为
parseJSON
函数提供一个实例:instance FromJSON Mathematician where parseJSON (Object v) = Mathematician <$> (v .: "name") <*> (v .: "nationality") <*> (v .: "born") <*> (v .:? "died")
-
如下所示定义并实现
main
:main :: IO () main = do
-
阅读输入并解码 JSON,如下代码片段所示:
input <- B.readFile "input.json" let mm = decode input :: Maybe Mathematician case mm of Nothing -> print "error parsing JSON" Just m -> (putStrLn.greet) m
-
现在我们将对数据做一些有趣的操作,如下所示:
greet m = (show.name) m ++ " was born in the year " ++ (show.born) m
-
我们可以运行代码以查看以下输出:
$ runhaskell Main.hs "Gauss" was born in the year 1777
它是如何工作的...
Aeson 处理表示 JSON 的复杂性。它将结构化文本转换为本地可用的数据。在本食谱中,我们使用Data.Aeson
模块提供的.:
和.:?
函数。
由于Aeson
包使用ByteStrings
而非Strings
,因此很有帮助的是告诉编译器引号中的字符应该被当作正确的数据类型处理。这是在代码的第一行通过调用OverloadedStrings
语言扩展来实现的。
提示
如今,OverloadedStrings
等语言扩展目前仅Glasgow Haskell Compiler(GHC)支持。
我们使用 Aeson 提供的decode
函数将字符串转换为数据类型。它的类型为FromJSON a => B.ByteString -> Maybe a
。我们的Mathematician
数据类型必须实现FromJSON
类型类的实例才能正确使用此函数。幸运的是,实现FromJSON
所需的唯一函数是parseJSON
。本食谱中用于实现parseJSON
的语法有些奇怪,但这是因为我们正在利用应用函数和镜头,这是更高级的 Haskell 主题。
.:
函数有两个参数,Object
和Text
,并返回一个Parser a
数据类型。根据文档,它用于检索与给定键相关联的对象中的值。如果 JSON 文档中存在该键和值,则使用此函数。:?
函数也从给定键的对象中检索关联值,但键和值的存在不是必需的。因此,对于 JSON 文档中的可选键值对,我们使用.:?
。
还有更多...
如果FromJSON
类型类的实现过于复杂,我们可以轻松地让 GHC 通过DeriveGeneric
语言扩展自动填充它。以下是代码的简化重写:
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE DeriveGeneric #-}
import Data.Aeson
import qualified Data.ByteString.Lazy as B
import GHC.Generics
data Mathematician = Mathematician { name :: String
, nationality :: String
, born :: Int
, died :: Maybe Int
} deriving Generic
instance FromJSON Mathematician
main = do
input <- B.readFile "input.json"
let mm = decode input :: Maybe Mathematician
case mm of
Nothing -> print "error parsing JSON"
Just m -> (putStrLn.greet) m
greet m = (show.name) m ++" was born in the year "++ (show.born) m
尽管 Aeson 功能强大且具有通用性,但对于一些简单的 JSON 交互,它可能显得过于复杂。作为替代,如果我们希望使用一个非常简洁的 JSON 解析器和打印器,可以使用 Yocto,它可以从hackage.haskell.org/package/yocto
下载。
使用 HXT 包读取 XML 文件
可扩展标记语言(XML)是对纯文本的编码,旨在为文档提供机器可读的注释。该标准由 W3C 指定(www.w3.org/TR/2008/REC-xml-20081126/
)。
在这个示例中,我们将解析一个表示电子邮件对话的 XML 文档,并提取所有日期。
准备就绪
我们首先设置一个名为 input.xml
的 XML 文件,包含以下值,表示 2014 年 12 月 18 日 Databender 和 Princess 之间的电子邮件对话,如下所示:
$ cat input.xml
<thread>
<email>
<to>Databender</to>
<from>Princess</from>
<date>Thu Dec 18 15:03:23 EST 2014</date>
<subject>Joke</subject>
<body>Why did you divide sin by tan?</body>
</email>
<email>
<to>Princess</to>
<from>Databender</from>
<date>Fri Dec 19 3:12:00 EST 2014</date>
<subject>RE: Joke</subject>
<body>Just cos.</body>
</email>
</thread>
使用 Cabal 安装 HXT 库,我们用它来处理 XML 文档:
$ cabal install hxt
它是如何做的...
-
我们只需要一个导入,用于解析 XML,代码如下:
import Text.XML.HXT.Core
-
定义并实现
main
函数并指定 XML 的位置。对于此示例,文件从input.xml
获取。参考以下代码:main :: IO () main = do input <- readFile "input.xml"
-
将
readString
函数应用于输入并提取所有日期文档。我们使用hasName :: String -> a XmlTree XmlTree
函数筛选具有特定名称的项目。同时,我们使用getText :: a XmlTree String
函数提取文本,如下所示的代码片段:dates <- runX $ readString [withValidate no] input //> hasName "date" //> getText
-
我们现在可以按如下方式使用提取的日期列表:
print dates
-
运行代码后,我们打印出以下输出:
$ runhaskell Main.hs ["Thu Dec 18 15:03:23 EST 2014", "Fri Dec 19 3:12:00 EST 2014"]
它是如何工作的...
库函数 runX
接受一个 Arrow。可以将 Arrow 看作是比 Monad 更强大的版本。Arrows 允许进行有状态的全局 XML 处理。具体来说,本示例中的 runX
函数接受 IOSArrow XmlTree String
,并返回一个类型为 String
的 IO
动作。我们使用 readString
函数生成此 IOSArrow
对象,它对 XML 数据执行一系列操作。
对于深入了解 XML 文档,应该使用 //>
,而 />
只查看当前级别。我们使用 //>
函数查找日期属性并显示所有关联文本。
如文档中所定义,hasName
函数用于测试一个节点是否具有特定名称,getText
函数用于选择文本节点的文本。还有其他一些函数,包括:
-
isText
:用于测试文本节点 -
isAttr
:用于测试属性树 -
hasAttr
:用于测试一个元素节点是否具有特定名称的属性节点 -
getElemName
:用于选择元素节点的名称
所有的箭头函数都可以在 Text.XML.HXT.Arrow.XmlArrow
文档中找到,链接:hackage.haskell.org/package/hxt/docs/Text-XML-HXT-Arrow-XmlArrow.html
。
从 HTML 页面捕获表格行
挖掘超文本标记语言(HTML)通常是一项识别和解析其结构化部分的工作。并非 HTML 文件中的所有文本都是有用的,所以我们往往只关注特定的子集。例如,HTML 表格和列表提供了一种强大且常用的结构来提取数据,而文章中的段落可能过于无结构和复杂,不易处理。
在本配方中,我们将找到网页上的一个表格,并收集所有行以供程序使用。
准备工作
我们将从 HTML 表格中提取值,所以首先创建一个包含表格的input.html
文件,如下图所示:
该表格背后的 HTML 如下所示:
$ cat input.html
<!DOCTYPE html>
<html>
<body>
<h1>Course Listing</h1>
<table>
<tr>
<th>Course</th>
<th>Time</th>
<th>Capacity</th>
</tr>
<tr>
<td>CS 1501</td>
<td>17:00</td>
<td>60</td>
</tr>
<tr>
<td>MATH 7600</td>
<td>14:00</td>
<td>25</td>
</tr>
<tr>
<td>PHIL 1000</td>
<td>9:30</td>
<td>120</td>
</tr>
</table>
</body>
</html>
如果尚未安装,请使用 Cabal 来设置 HXT 库和 split 库,如下命令所示:
$ cabal install hxt
$ cabal install split
如何实现...
-
我们将需要
htx
包用于 XML 操作,以及来自 split 包的chunksOf
函数,如以下代码片段所示:import Text.XML.HXT.Core import Data.List.Split (chunksOf)
-
定义并实现
main
来读取input.html
文件。main :: IO () main = do input <- readFile "input.html"
-
将 HTML 数据传递给
readString
,设置withParseHTML
为yes
,并可选择关闭警告。提取所有td
标签并获取剩余文本,如以下代码所示:texts <- runX $ readString [withParseHTML yes, withWarnings no] input //> hasName "td" //> getText
-
现在数据可以作为字符串列表使用。它可以像之前 CSV 配方中展示的那样,转换为列表的列表,如以下代码所示:
let rows = chunksOf 3 texts print $ findBiggest rows
-
通过折叠数据,使用以下代码片段识别容量最大课程:
findBiggest :: [[String]] -> [String] findBiggest [] = [] findBiggest items = foldl1 (\a x -> if capacity x > capacity a then x else a) items capacity [a,b,c] = toInt c capacity _ = -1 toInt :: String -> Int toInt = read
-
运行代码将显示容量最大的课程,如下所示:
$ runhaskell Main.hs {"PHIL 1000", "9:30", "120"}
它是如何工作的...
这与 XML 解析非常相似,只是我们调整了readString
的选项为[withParseHTML yes, withWarnings no]
。
了解如何执行 HTTP GET 请求
寻找好数据的最有资源的地方之一就是在线。GET 请求是与 HTTP 网页服务器通信的常用方法。在这个配方中,我们将抓取维基百科文章中的所有链接并将其打印到终端。为了方便地抓取所有链接,我们将使用一个叫做HandsomeSoup
的有用库,它可以让我们通过 CSS 选择器轻松地操作和遍历网页。
准备工作
我们将从一个维基百科网页中收集所有链接。在运行此配方之前,请确保已连接互联网。
安装HandsomeSoup
CSS 选择器包,如果尚未安装 HXT 库,请安装它。为此,请使用以下命令:
$ cabal install HandsomeSoup
$ cabal install hxt
如何实现...
-
此配方需要
hxt
来解析 HTML,并需要HandsomeSoup
来提供易于使用的 CSS 选择器,如以下代码片段所示:import Text.XML.HXT.Core import Text.HandsomeSoup
-
按如下方式定义并实现
main
:main :: IO () main = do
-
将 URL 作为字符串传递给 HandsomeSoup 的
fromUrl
函数:let doc = fromUrl "http://en.wikipedia.org/wiki/Narwhal"
-
按如下方式选择维基百科页面中
bodyContent
字段内的所有链接:links <- runX $ doc >>> css "#bodyContent a" ! "href" print links
它是如何工作的…
HandsomeSoup
包允许使用简易的 CSS 选择器。在此配方中,我们在 Wikipedia 文章网页上运行 #bodyContent a
选择器。这将找到所有作为bodyContent
ID 元素后代的链接标签。
另见…
另一种常见的在线获取数据的方法是通过 POST 请求。要了解更多信息,请参考学习如何执行 HTTP POST 请求的配方。
学习如何执行 HTTP POST 请求
POST 请求是另一种非常常见的 HTTP 服务器请求,许多 API 都在使用它。我们将挖掘弗吉尼亚大学的目录搜索。当发送一个用于搜索查询的 POST 请求时,轻量级目录访问协议(LDAP)服务器会返回一个包含搜索结果的网页。
准备就绪
此配方需要访问互联网。
安装HandsomeSoup
CSS 选择器包,如果尚未安装,还需要安装 HXT 库:
$ cabal install HandsomeSoup
$ cabal install hxt
如何操作...
-
导入以下库:
import Network.HTTP import Network.URI (parseURI) import Text.XML.HXT.Core import Text.HandsomeSoup import Data.Maybe (fromJust)
-
定义目录搜索网站指定的 POST 请求。根据服务器的不同,以下 POST 请求的细节会有所不同。请参考以下代码片段:
myRequestURL = "http://www.virginia.edu/cgi-local/ldapweb" myRequest :: String -> Request_String myRequest query = Request { rqURI = fromJust $ parseURI myRequestURL , rqMethod = POST , rqHeaders = [ mkHeader HdrContentType "text/html" , mkHeader HdrContentLength $ show $ length body ] , rqBody = body } where body = "whitepages=" ++ query
-
定义并实现
main
来运行如下的 POST 请求:main :: IO () main = do response <- simpleHTTP $ myRequest "poon"
-
收集 HTML 并进行解析:
html <- getResponseBody response let doc = readString [withParseHTML yes, withWarnings no] html
-
查找表格行并使用以下代码打印输出:
rows <- runX $ doc >>> css "td" //> getText print rows
运行代码将显示与"poon"
相关的所有搜索结果,如“Poonam”或“Witherspoon”。
它是如何工作的...
POST 请求需要指定的 URI、头信息和主体。通过填写Request
数据类型,可以用来建立服务器请求。
另见
请参考理解如何执行 HTTP GET 请求的配方,了解如何执行 GET 请求的详细信息。
遍历在线目录以获取数据
目录搜索通常会根据查询提供姓名和联系方式。通过强行进行多个搜索查询,我们可以获取目录列表数据库中存储的所有数据。此配方仅作为学习工具,用于展示 Haskell 数据收集的强大和简便性。
准备就绪
确保拥有强劲的互联网连接。
使用 Cabal 安装hxt
和HandsomeSoup
包:
$ cabal install hxt
$ cabal install HandsomeSoup
如何操作...
-
设置以下依赖项:
import Network.HTTP import Network.URI import Text.XML.HXT.Core import Text.HandsomeSoup
-
定义一个
SearchResult
类型,它可能会失败并返回错误,或返回成功,如以下代码所示:type SearchResult = Either SearchResultErr [String] data SearchResultErr = NoResultsErr | TooManyResultsErr | UnknownErr deriving (Show, Eq)
-
定义目录搜索网站指定的 POST 请求。根据服务器的不同,POST 请求会有所不同。为了避免重写代码,我们使用在上一个配方中定义的
myRequest
函数。 -
编写一个辅助函数来获取 HTTP POST 请求的文档,如下所示:
getDoc query = do rsp <- simpleHTTP $ myRequest query html <- getResponseBody rsp return $ readString [withParseHTML yes, withWarnings no] html
-
扫描 HTML 文档并返回是否有错误,或者提供结果数据。此函数中的代码依赖于网页生成的错误消息。在我们的案例中,错误消息如下:
scanDoc doc = do errMsg <- runX $ doc >>> css "h3" //> getText case errMsg of [] -> do text <- runX $ doc >>> css "td" //> getText return $ Right text "Error: Sizelimit exceeded":_ -> return $ Left TooManyResultsErr "Too many matching entries were found":_ -> return $ Left TooManyResultsErr "No matching entries were found":_ -> return $ Left NoResultsErr _ -> return $ Left UnknownErr
-
定义并实现
main
。我们将使用一个辅助函数main'
,如下所示的代码片段中,将递归地强行列出目录:main :: IO () main = main' "a"
-
执行查询搜索,然后在下一个查询中递归执行:
main' query = do print query doc <- getDoc query searchResult <- scanDoc doc print searchResult case searchResult of Left TooManyResultsErr -> main' (nextDeepQuery query) _ -> if (nextQuery query) >= endQuery then print "done!" else main' (nextQuery query)
-
编写辅助函数来定义下一个逻辑查询,如下所示:
nextDeepQuery query = query ++ "a" nextQuery "z" = endQuery nextQuery query = if last query == 'z' then nextQuery $ init query else init query ++ [succ $ last query] endQuery = [succ 'z']
它是如何工作的……
代码开始时会在目录查找中搜索 "a"。这很可能会由于结果过多而发生错误。因此,在下一次迭代中,代码会通过查询 "aa" 来细化搜索,再接着是 "aaa",直到不再出现 TooManyResultsErr :: SearchResultErr
。
然后,它将枚举到下一个逻辑搜索查询 "aab",如果没有结果,它将搜索 "aac",依此类推。这个强制前缀搜索将获取数据库中的所有项目。我们可以收集大量数据,比如姓名和部门类型,稍后进行有趣的聚类或分析。下图展示了程序的启动方式:
在 Haskell 中使用 MongoDB 查询
MongoDB 是一个非关系型的无模式数据库。在这个方法中,我们将把所有数据从 MongoDB 获取到 Haskell 中。
准备工作
我们需要在本地机器上安装 MongoDB,并在运行此方法中的代码时,确保后台有一个数据库实例在运行。
MongoDB 安装说明位于 www.mongodb.org
。在基于 Debian 的操作系统中,我们可以使用 apt-get
安装 MongoDB,命令如下:
$ sudo apt-get install mongodb
通过指定数据库文件路径,运行数据库守护进程,方法如下:
$ mkdir ~/db
$ mongod --dbpath ~/db
填充一个名为 "people"
的集合,插入虚拟数据,方法如下:
$ mongo
> db.people.insert( {first: "Joe", last: "Shmoe"} )
使用以下命令从 Cabal 安装 MongoDB 包:
$ cabal install mongoDB
如何做……
-
使用
OverloadedString
和ExtendedDefaultRules
语言扩展来使 MongoDB 库更容易使用:{-# LANGUAGE OverloadedStrings, ExtendedDefaultRules #-} import Database.MongoDB
-
定义并实现
main
来设置与本地托管数据库的连接。运行run
函数中定义的 MongoDB 查询,方法如下:main :: IO () main = do let db = "test" pipe <- runIOE $ connect (host "127.0.0.1") e <- access pipe master db run close pipe print e
-
在
run
中,我们可以结合多个操作。对于这个方法,run
将只执行一个任务,即从"people"
集合中收集数据:run = getData getData = rest =<< find (select [] "people") {sort=[]}
它是如何工作的……
驱动程序在运行的程序与数据库之间建立了管道。这使得运行 MongoDB 操作能够将程序与数据库连接起来。find
函数接收一个查询,我们通过调用 select :: Selector -> Collection -> aQueryOrSelection
函数来构建查询。
其他函数可以在文档中找到:hackage.haskell.org/package/mongoDB/docs/Database-MongoDB-Query.html
另见
如果 MongoDB 数据库在远程服务器上,请参考从远程 MongoDB 服务器读取数据这一方法,来设置与远程数据库的连接。
从远程 MongoDB 服务器读取数据
在许多情况下,可能在远程计算机上设置 MongoDB 实例更加可行。本做法将介绍如何从远程托管的 MongoDB 获取数据。
准备工作
我们应创建一个远程数据库。MongoLab(mongolab.com
)和 MongoHQ(www.mongohq.com
)提供作为服务的 MongoDB,并且有免费的选项来设置一个小型开发数据库。
提示
这些服务要求我们接受其条款和条件。对某些人来说,将数据库托管在我们自己的远程服务器上可能是最好的选择。
按如下方式从 Cabal 安装 MongoDB 包:
$ cabal install mongoDB
还需安装以下辅助库:
$ cabal install split
$ cabal install uri
如何做……
-
使用库所需的
OverloadedString
和ExtendedDefaultRules
语言扩展。按如下方式导入辅助函数:{-# LANGUAGE OverloadedStrings, ExtendedDefaultRules #-} import Database.MongoDB import Text.URI import Data.Maybe import qualified Data.Text as T import Data.List.Split
-
按如下方式指定数据库连接的远程 URI:
mongoURI = "mongodb://user:pass@ds12345.mongolab.com:53788/mydb"
-
用户名、密码、主机名、端口地址和数据库名称必须从 URI 中提取,如下代码片段所示:
uri = fromJust $ parseURI mongoURI getUser = head $ splitOn ":" $ fromJust $ uriUserInfo uri getPass = last $ splitOn ":" $ fromJust $ uriUserInfo uri getHost = fromJust $ uriRegName uri getPort = case uriPort uri of Just port -> show port Nothing -> (last.words.show) defaultPort getDb = T.pack $ tail $ uriPath uri
-
通过读取远程 URI 的主机端口来创建数据库连接,如下所示:
main :: IO () main = do let hostport = getHost ++ ":" ++ getPort pipe <- runIOE $ connect (readHostPort hostport) e <- access pipe master getDb run close pipe print e
-
可选地,对数据库进行身份验证并按如下方式从
"people"
集合中获取数据:run = do auth (T.pack getUser) (T.pack getPass) getData getData = rest =<< find (select [] "people") {sort=[]}
另见
如果数据库在本地计算机上,请参阅在 Haskell 中使用 MongoDB 查询这一做法。
探索 SQLite 数据库中的数据
SQLite 是一个关系型数据库,它执行严格的模式。它仅仅是机器上的一个文件,我们可以通过结构化查询语言(SQL)与之交互。Haskell 有一个易于使用的库来将这些 SQL 命令发送到我们的数据库。
在本做法中,我们将使用这样的库来提取 SQLite 数据库中的所有数据。
准备工作
如果 SQLite 数据库尚未设置,我们需要先安装它。可以从www.sqlite.org
获取。在 Debian 系统中,我们可以通过以下命令从apt-get
获取:
$ sudo apt-get install sqlite3
现在创建一个简单的数据库来测试我们的代码,使用以下命令:
$ sqlite3 test.db "CREATE TABLE test \
(id INTEGER PRIMARY KEY, str text); \
INSERT INTO test (str) VALUES ('test string');"
我们还必须按如下方式从 Cabal 安装 SQLite Haskell 包:
$ cabal install sqlite-simple
本做法将详细分析库文档页面上展示的示例代码,页面地址为hackage.haskell.org/package/sqlite-simple/docs/Database-SQLite-Simple.html
。
如何做……
-
使用
OverloadedStrings
语言扩展并导入相关库,如下代码所示:{-# LANGUAGE OverloadedStrings #-} import Control.Applicative import Database.SQLite.Simple import Database.SQLite.Simple.FromRow
-
为每个 SQLite 表字段定义一个数据类型。为它提供
FromRow
类型类的实例,以便我们可以轻松地从表中解析它,如下代码片段所示:data TestField = TestField Int String deriving (Show) instance FromRow TestField where fromRow = TestField <$> field <*> field
-
最后,按如下方式打开数据库并导入所有内容:
main :: IO () main = do conn <- open "test.db" r <- query_ conn "SELECT * from test" :: IO [TestField] mapM_ print r close conn
第二章 完整性与检查
本章将涵盖以下内容:
-
去除多余的空格
-
忽略标点符号和特定字符
-
处理意外或缺失的输入
-
通过匹配正则表达式验证记录
-
对电子邮件地址进行词法分析和解析
-
去重无冲突的数据项
-
去重有冲突的数据项
-
使用 Data.List 实现频率表
-
使用 Data.MultiSet 实现频率表
-
计算曼哈顿距离
-
计算欧几里得距离
-
使用 Pearson 相关系数比较缩放后的数据
-
使用余弦相似度比较稀疏数据
介绍
从数据分析中得出的结论的稳健性仅取决于数据本身的质量。在获得原始文本后,下一步自然是仔细验证和清理它。即使是最轻微的偏差也可能危及结果的完整性。因此,我们必须采取严格的预防措施,包括全面检查,以确保在开始理解数据之前对数据进行合理性检查。本节应为在 Haskell 中清理数据的起点。
现实世界中的数据通常带有一些杂质,需要在处理之前进行清理。例如,多余的空格或标点符号可能会使数据混乱,难以解析。重复和数据冲突是读取现实世界数据时常见的意外后果。有时,通过执行合理性检查来确保数据是有意义的,这会令人放心。一些合理性检查的例子包括匹配正则表达式以及通过建立距离度量来检测离群值。本章将涵盖这些主题。
去除多余的空格
从源获取的文本可能会无意中包含开头或结尾的空格字符。在解析此类输入时,通常明智的做法是修剪文本。例如,当 Haskell 源代码包含尾部空格时,GHC 编译器会通过称为 词法分析 的过程忽略它。词法分析器生成一系列标记,实际上忽略了像多余空格这样的无意义字符。
在本例中,我们将使用内置库来制作我们自己的 trim
函数。
如何实现...
创建一个新的文件,我们称之为 Main.hs
,并执行以下步骤:
-
从内置的
Data.Char
包中导入isSpace :: Char -> Bool
函数:import Data.Char (isSpace)
-
编写一个
trim
函数,去除开头和结尾的空格:trim :: String -> String trim = f . f where f = reverse . dropWhile isSpace
-
在
main
中测试:main :: IO () main = putStrLn $ trim " wahoowa! "
-
运行代码将得到以下修剪后的字符串:
$ runhaskell Main.hs wahoowa!
它是如何工作的...
我们的trim
函数懒加载地去除了字符串开头和结尾的空白。它首先删除字符串开头的空白字符。然后,它会将字符串反转,再次应用同样的函数。最后,它会将字符串再反转一次,使其恢复到原来的形式。幸运的是,Data.Char
中的isSpace
函数处理了所有Unicode空白字符以及控制字符\t
、\n
、\r
、\f
和\v
。
还有更多...
现成的解析器组合库,如parsec
或uu-parsinglib
,可以用来实现这一功能,而不是重新发明轮子。通过引入Token
类型并解析为该类型,我们可以优雅地忽略空白字符。或者,我们可以使用 alex 词法分析库(包名alex
)来完成此任务。虽然这些库对于这个简单的任务来说有些过于复杂,但它们允许我们对文本进行更通用的标记化处理。
忽略标点符号和特定字符
通常在自然语言处理(NLP)中,一些没有信息量的单词或字符,被称为停用词,可以被过滤掉,以便更容易处理。在计算单词频率或从语料库中提取情感数据时,可能需要忽略标点符号或特殊字符。本示例演示了如何从文本主体中去除这些特定字符。
如何实现...
不需要任何导入。创建一个新文件,我们称之为Main.hs
,并执行以下步骤:
-
实现
main
并定义一个名为quote
的字符串。反斜杠(\
)表示多行字符串:main :: IO () main = do let quote = "Deep Blue plays very good chess-so what?\ \Does that tell you something about how we play chess?\ \No. Does it tell you about how Kasparov envisions,\ \understands a chessboard? (Douglas Hofstadter)" putStrLn $ (removePunctuation.replaceSpecialSymbols) quote
-
将所有标点符号替换为空字符串,并将所有特殊符号替换为空格:
punctuations = [ '!', '"', '#', '$', '%' , '(', ')', '.', ',', '?'] removePunctuation = filter (`notElem` punctuations) specialSymbols = ['/', '-'] replaceSpecialSymbols = map $ (\c ->if c `elem` specialSymbols then ' ' else c)
-
通过运行代码,我们将发现所有特殊字符和标点符号已被适当移除,以便处理文本语料库。
$ runhaskell Main.hs Deep Blue plays very good chess so what Does that tell you something about how we play chess No Does it tell you about how Kasparov envisions understands a chessboard Douglas Hofstadter
还有更多...
为了更强大的控制,我们可以安装MissingH
,这是一款非常有用的工具,可用于处理字符串:
$ cabal install MissingH
它提供了一个replace
函数,接受三个参数并产生如下结果:
Prelude> replace "hello" "goodbye" "hello world!"
"goodbye world!"
它将第一个字符串的所有出现位置替换为第三个参数中的第二个字符串。我们还可以组合多个replace
函数:
Prelude> ((replace "," "").(replace "!" "")) "hello, world!"
"hello world"
通过将组合函数(.)
应用于这些replace
函数的列表,我们可以将replace
函数推广到任意的符号列表:
Prelude> (foldr (.) id $ map (flip replace "") [",", "!"]) "hello, world!"
"hello world"
现在,标点符号的列表可以是任意长度的。我们可以修改我们的示例,使用新的、更通用的函数:
removePunctuation = foldr (.) id $ map (flip replace "")
["!", "\"", "#", "$", "%", "(", ")", ".", ",", "?"]
replaceSpecialSymbols = foldr (.) id $ map (flip replace " ")
["/", "-"]
应对意外或缺失的输入
数据源通常包含不完整和意外的数据。在 Haskell 中处理这种数据的一种常见方法是使用Maybe
数据类型。
想象一下设计一个函数来查找字符列表中的第 n 个元素。一个简单的实现可能是类型为Int -> [Char] -> Char
。然而,如果该函数试图访问一个越界的索引,我们应该尝试指示发生了错误。
处理这些错误的一种常见方法是将输出的Char
封装在Maybe
上下文中。拥有Int -> [Char] -> Maybe Char
类型可以提供更好的错误处理。Maybe
的构造函数是Just a
或Nothing
,通过运行 GHCi 并测试以下命令会变得明显:
$ ghci
Prelude> :type Just 'c'
Just 'c' :: Maybe Char
Prelude> :type Nothing
Nothing :: Maybe a
我们将每个字段设置为Maybe
数据类型,这样当某个字段无法解析时,它将简单地表示为Nothing
。这个食谱将演示如何读取包含错误和缺失信息的 CSV 数据。
准备就绪
我们创建一个 CSV 文件输入集来读取。第一列是笔记本品牌,第二列是其型号,第三列是基础费用。我们应该留下一些字段为空,以模拟不完整的输入。我们将文件命名为input.csv
:
同时,我们还必须安装 csv 库:
$ cabal install csv
如何操作...
创建一个新文件,我们将其命名为Main.hs
,并执行以下步骤:
-
导入 CSV 库:
import Text.CSV
-
创建一个对应于 CSV 字段的数据类型:
data Laptop = Laptop { brand :: Maybe String , model :: Maybe String , cost :: Maybe Float } deriving Show
-
定义并实现
main
来读取 CSV 输入并解析相关信息:main :: IO () main = do let fileName = "input.csv" input <- readFile fileName let csv = parseCSV fileName input let laptops = parseLaptops csv print laptops
-
从记录列表中创建一个笔记本数据类型列表:
parseLaptops (Left err) = [] parseLaptops (Right csv) = foldl (\a record -> if length record == 3 then (parseLaptop record):a else a) [] csv parseLaptop record = Laptop{ brand = getBrand $ record !! 0 , model = getModel $ record !! 1 , cost = getCost $ record !! 2 }
-
解析每个字段,如果出现意外或缺失的项,则生成
Nothing
:getBrand :: String -> Maybe String getBrand str = if null str then Nothing else Just str getModel :: String -> Maybe String getModel str = if null str then Nothing else Just str getCost :: String -> Maybe Float getCost str = case reads str::[(Float,String)] of [(cost, "")] -> Just cost _ -> Nothing
它是如何工作的...
Maybe
单子允许你有两种状态:Just
某物或Nothing
。它提供了一种有用的抽象来产生错误状态。这些数据类型中的每个字段都存在于Maybe
上下文中。如果字段不存在,我们简单地将其视为Nothing
并继续。
还有更多内容...
如果希望有更具描述性的错误状态,Either
单子可能更有用。它也有两种状态,但它们更具描述性:Left
某物,或Right
某物。Left
状态通常用来描述错误类型,而Right
状态则包含期望的结果。我们可以使用Left
状态来描述不同类型的错误,而不仅仅是一个庞大的Nothing
。
另见
要复习 CSV 数据输入,请参阅第一章中的保存和表示 CSV 文件中的数据食谱,数据探索。
通过匹配正则表达式验证记录
正则表达式是一种用于匹配字符串中模式的语言。我们的 Haskell 代码可以处理正则表达式来检查文本并告诉我们它是否符合表达式描述的规则。正则表达式匹配可用于验证或识别文本中的模式。
在这个食谱中,我们将读取一篇英文文本语料库,从大量的单词中找出可能的全名。全名通常由两个以大写字母开头的单词组成。我们利用这个启发式方法从文章中提取所有的名字。
准备就绪
创建一个包含文本的input.txt
文件。在此示例中,我们使用来自《纽约时报》关于恐龙的文章片段(www.nytimes.com/2013/12/17/science/earth/outsider-challenges-papers-on-growth-of-dinosaurs.html
)
埃里克森博士的其他合著者包括美国自然历史博物馆古生物学主席马克·诺雷尔;阿尔伯塔大学恐龙古生物学教授菲利普·卡里;以及芝加哥田野博物馆古生物学副馆长彼得·马科维基。
如何操作…
创建一个新文件,我们将其命名为Main.hs
,并执行以下步骤:
-
导入正则表达式库:
import Text.Regex.Posix ((=~))
-
将字符串与正则表达式进行匹配,以检测看起来像名字的单词:
looksLikeName :: String -> Bool looksLikeName str = str =~ "^[A-Z][a-z]{1,30}$" :: Bool
-
创建去除不必要标点符号和特殊符号的函数。我们将使用前一个食谱中定义的相同函数,标题为忽略标点符号和特定字符:
punctuations = [ '!', '"', '#', '$', '%' , '(', ')', '.', ',', '?'] removePunctuation = filter (`notElem` punctuations) specialSymbols = ['/', '-'] replaceSpecialSymbols = map $ (\c -> if c `elem` specialSymbols then ' ' else c)
-
将相邻的单词配对,并形成一个可能的全名列表:
createTuples (x:y:xs) = (x ++ " " ++ y) : createTuples (y:xs) createTuples _ = []
-
检索输入并从文本语料库中查找可能的名称:
main :: IO () main = do input <- readFile "input.txt" let cleanInput = (removePunctuation.replaceSpecialSymbols) input let wordPairs = createTuples $ words cleanInput let possibleNames = filter (all looksLikeName . words) wordPairs print possibleNames
-
运行代码后的结果输出如下:
$ runhaskell Main.hs ["Dr Erickson","Mark Norell","American Museum","Natural History","History Philip","Philip Currie","Peter Makovicky","Field Museum"]
它是如何工作的...
=~
函数接受一个字符串和一个正则表达式,并返回我们解析为Bool
的目标。在本食谱中,^[A-Z][a-z]{1,30}$
正则表达式匹配以大写字母开头、长度在 2 到 31 个字母之间的单词。
为了确定本食谱中所呈现算法的有效性,我们将引入两个相关性指标:精确度和召回率。精确度是指检索到的数据中相关数据所占的百分比。召回率是指相关数据中被检索到的百分比。
在input.txt
文件中的 45 个单词中,产生了四个正确的名字,并且总共检索到八个候选项。它的精确度为 50%,召回率为 100%。对于一个简单的正则表达式技巧来说,这个结果相当不错。
另见
我们可以通过词法分析器而不是直接在字符串上运行正则表达式。下一个名为词法分析和解析电子邮件地址的食谱将详细讲解这一点。
词法分析和解析电子邮件地址
清理数据的一种优雅方法是定义一个词法分析器,将字符串拆分成标记。在本食谱中,我们将使用attoparsec
库解析电子邮件地址。这自然允许我们忽略周围的空格。
准备工作
导入attoparsec
解析器组合器库:
$ cabal install attoparsec
如何操作…
创建一个新文件,我们将其命名为Main.hs
,并执行以下步骤:
-
使用 GHC 的
OverloadedStrings
语言扩展,以便在代码中更清晰地使用Text
数据类型。同时,导入其他相关库:{-# LANGUAGE OverloadedStrings #-} import Data.Attoparsec.Text import Data.Char (isSpace, isAlphaNum)
-
声明一个电子邮件地址的数据类型:
data E-mail = E-mail { user :: String , host :: String } deriving Show
-
定义如何解析电子邮件地址。这个函数可以根据需要简单或复杂:
e-mail :: Parser E-mail e-mail = do skipSpace user <- many' $ satisfy isAlphaNum at <- char '@' hostName <- many' $ satisfy isAlphaNum period <- char '.' domain <- many' (satisfy isAlphaNum) return $ E-mail user (hostName ++ "." ++ domain)
-
解析电子邮件地址以测试代码:
main :: IO () main = print $ parseOnly e-mail "nishant@shukla.io"
-
运行代码打印出解析后的电子邮件地址:
$ runhaskell Main.hs Right (E-mail {user = "nishant", host = "shukla.io"})
它是如何工作的……
我们通过将字符串与多个测试匹配来创建电子邮件解析器。电子邮件地址必须包含一个字母数字的用户名,后跟“at”符号(@
),然后是字母数字的主机名,一个句点,最后是顶级域名。
使用的各种attoparsec
库函数可以在Data.Attoparsec.Text
文档中找到,文档地址为hackage.haskell.org/package/attoparsec/docs/Data-Attoparsec-Text.html
。
无冲突数据项去重
数据重复是收集大量数据时常见的问题。在本篇中,我们将以确保不丢失信息的方式合并相似的记录。
准备中
创建一个包含重复数据的input.csv
文件:
如何做……
创建一个新的文件,我们将其命名为Main.hs
,并执行以下步骤:
-
我们将使用
CSV
、Map
和Maybe
包:import Text.CSV (parseCSV, Record) import Data.Map (fromListWith) import Control.Applicative ((<|>))
-
定义与 CSV 输入对应的
Item
数据类型:data Item = Item { name :: String , color :: Maybe String , cost :: Maybe Float } deriving Show
-
从 CSV 获取每条记录,并通过调用我们的
doWork
函数将它们放入映射中:main :: IO () main = do let fileName = "input.csv" input <- readFile fileName let csv = parseCSV fileName input either handleError doWork csv
-
如果无法解析 CSV,打印错误消息;否则,定义
doWork
函数,该函数根据由combine
定义的碰撞策略从关联列表创建映射:handleError = print doWork :: [Record] -> IO () doWork csv = print $ fromListWith combine $ map parseToTuple csv
-
使用
Control.Applicative
中的<|>
函数合并无冲突字段:combine :: Item -> Item -> Item combine item1 item2 = Item { name = name item1 , color = color item1 <|> color item2 , cost = cost item1 <|> cost item2 }
-
定义辅助函数,从 CSV 记录创建关联列表:
parseToTuple :: [String] -> (String, Item) parseToTuple record = (name item, item) where item = parseItem record parseItem :: Record -> Item parseItem record = Item { name = record !! 0 , color = record !! 1 , cost = case reads(record !! 2)::[(Float,String)] of [(c, "")] -> Just c _ -> Nothing }
-
执行代码显示一个填充了合并结果的映射:
$ runhaskell Main.hs fromList [ ("glasses", Item {name = "glasses", color = "black", cost = Just 60.0}) , ("jacket", Item {name = "jacket", color = "brown", cost = Just 89.99}) , ("shirt", Item {name = "shirt", color = "red", cost = Just 15.0}) ]
它是如何工作的……
Map
数据类型提供了一个便捷的函数fromListWith :: Ord k => (a -> a -> a) -> [(k, a)] -> Map k a
,用于轻松地合并映射中的数据。我们使用它来检查一个键是否已经存在。如果存在,我们将旧项目和新项目中的字段合并,并将它们存储在该键下。
本篇的真正英雄是Control.Applicative
中的<|>
函数。<|>
函数接受其参数,并返回第一个非空的参数。由于String
和Maybe
都实现了Applicative
类型类,我们可以复用<|>
函数来简化代码。以下是几个使用示例:
$ ghci
Prelude> import Control.Applicative
Prelude Control.Applicative> (Nothing) <|> (Just 1)
Just 1
Prelude Control.Applicative> (Just 'a') <|> (Just 'b')
Just 'a'
Prelude Control.Applicative> "" <|> "hello"
"hello"
Prelude Control.Applicative> "" <|> ""
""
还有更多……
如果你处理的是较大的数字,可能明智之举是改用Data.Hashmap.Map
,因为对n项的运行时间是O(min(n, W)),其中W是整数的位数(32 或 64)。
为了更好的性能,Data.Hashtable.Hashtable
提供了O(1)的查找性能,但通过位于 I/O 单子中的复杂性增加了使用的难度。
另见:
如果语料库中包含关于重复数据的不一致信息,请参阅下一篇关于冲突数据项去重的内容。
冲突数据项去重
不幸的是,关于某一项的信息在语料库中可能是不一致的。冲突策略通常依赖于领域,但一种常见的管理冲突的方式是简单地存储所有数据的变体。在本示例中,我们将读取一个包含音乐艺术家信息的 CSV 文件,并将关于他们的歌曲和流派的所有信息存储在一个集合中。
准备就绪
创建一个 CSV 输入文件,包含以下音乐艺术家。第一列是艺术家或乐队的名称,第二列是歌曲名称,第三列是流派。请注意,一些音乐人有多首歌曲或多种流派。
如何操作...
创建一个新的文件,我们将其命名为Main.hs
,并执行以下步骤:
-
我们将使用
CSV
、Map
和Set
包:import Text.CSV (parseCSV, Record) import Data.Map (fromListWith) import qualified Data.Set as S
-
定义与 CSV 输入对应的
Artist
数据类型。对于可能包含冲突数据的字段,将其存储在相应的列表中。在这种情况下,与歌曲和流派相关的数据存储在一个字符串集合中:data Artist = Artist { name :: String , song :: S.Set String , genre :: S.Set String } deriving Show
-
从 CSV 中提取数据并将其插入到映射中:
main :: IO () main = do let fileName = "input.csv" input <- readFile fileName let csv = parseCSV fileName input either handleError doWork csv
-
打印出可能发生的任何错误:
handleError = print
-
如果没有错误发生,那么就将 CSV 中的数据合并并打印出来:
doWork :: [Record] -> IO () doWork csv = print $ fromListWith combine $ map parseToTuple csv
-
从关联列表创建一个映射,碰撞策略由
combine
定义:combine :: Artist -> Artist -> Artist combine artist1 artist2 = Artist { name = name artist1 , song = S.union (song artist1) (song artist2) , genre = S.union (genre artist1) (genre artist2) }
-
让辅助函数从 CSV 记录中创建关联列表:
parseToTuple :: [String] -> (String, Artist) parseToTuple record = (name item, item) where item = parseItem record parseItem :: Record -> Artist parseItem record = Artist { name = nameStr , song = if null songStr then S.empty else S.singleton songStr , genre = if null genreStr then S.empty else S.singleton genreStr } where nameStr = record !! 0 songStr = record !! 1 genreStr = record !! 2
-
程序的输出将是一个映射,包含将收集的以下信息:
fromList [ ("Daft Punk", Artist { name = "Daft Punk", song = fromList ["Get Lucky","Around the World"], genre = fromList ["French house"]}), ("Junior Boys", Artist { name = "Junior Boys", song = fromList ["Bits & Pieces"], genre = fromList ["Synthpop"]}), ("Justice", Artist { name = "Justice", song = fromList ["Genesis"], genre = fromList ["Electronic rock","Electro"]}), ("Madeon", Artist { name = "Madeon", song = fromList ["Icarus"], genre = fromList ["French house"]})]
它是如何工作的...
Map
数据类型提供了一个方便的函数fromListWith :: Ord k => (a -> a -> a) -> [(k, a)] -> Map k a
,可以轻松地在Map
中合并数据。我们使用它来查找键是否已存在。如果存在,则将旧项和新项中的字段合并,并将其存储在该键下。
我们使用集合来高效地组合这些数据字段。
还有更多内容...
如果处理较大的数字,可能明智地使用Data.Hashmap.Map
,因为处理* n 个项目的运行时间是 O(min(n, W)) ,其中 W *是整数中的位数(32 或 64)。
为了获得更好的性能,Data.Hashtable.Hashtable
为查找提供了O(1)性能,但通过处于 I/O monad 中增加了复杂性。
另见
如果语料库包含关于重复数据的无冲突信息,请参阅前一节关于去重无冲突数据项的内容。
使用 Data.List 实现频率表
值的频率映射通常用于检测异常值。我们可以用它来识别看起来不寻常的频率。在这个例子中,我们将计算列表中不同颜色的数量。
如何操作...
创建一个新的文件,我们将其命名为Main.hs
,并执行以下步骤:
-
我们将使用
Data.List
中的group
和sort
函数:import Data.List (group, sort)
-
为颜色定义一个简单的数据类型:
data Color = Red | Green | Blue deriving (Show, Ord, Eq)
-
创建以下颜色的列表:
main :: IO () main = do let items = [Red, Green, Green, Blue, Red, Green, Green]
-
实现频率映射并打印出来:
let freq = map (\x -> (head x, length x)) . group . sort $ items print freq
它是如何工作的...
对列表进行排序后,分组相同的项是核心思想。
请参阅以下在 ghci 中的逐步评估:
Prelude> sort items
[Red,Red,Green,Green,Green,Green,Blue]
Prelude> group it
[[Red,Red],[Green,Green,Green,Green],[Blue]]
Prelude> map (\x -> (head x, length x)) it
[(Red,2),(Green,4),(Blue,1)]
提示
正如我们所预期的那样,排序列表是最昂贵的步骤。
另请参阅
通过使用下一个食谱中描述的 Data.MultiSet
,代码可以更简洁,使用 Data.MultiSet 实现频率表。
使用 Data.MultiSet 实现频率表
值的频率图常常用于检测离群值。我们将使用一个现有的库,它为我们完成了大部分工作。
准备工作
我们将使用来自 Hackage 的 multiset
包:
$ cabal install multiset
如何做...
创建一个新文件,我们将其命名为 Main.hs
,并执行以下步骤:
-
我们将使用
Data.MultiSet
中的fromList
和toOccurList
函数:import Data.MultiSet (fromList, toOccurList)
-
定义一个简单的颜色数据类型:
data Color = Red | Green | Blue deriving (Show, Ord, Eq)
-
创建这些颜色的列表:
main :: IO () main = do let items = [Red, Green, Green, Blue, Red, Green, Green]
-
实现频率图并将其打印出来:
let freq = toOccurList . fromList $ items print freq
-
运行代码以显示频率列表:
$ runhaskell Main.hs [ (Red, 2), (Green, 4), (Blue, 1) ]
它是如何工作的...
toOccurList :: MultiSet a -> [(a, Int)]
函数从列表创建频率图。我们使用提供的 fromList
函数构造 MultiSet
。
另请参阅
如果不希望导入新的库,请参阅前一个食谱 使用 Data.List 实现频率图。
计算曼哈顿距离
定义两个物体之间的距离使我们能够轻松地解释簇和模式。曼哈顿距离是最容易实现的距离之一,主要由于其简单性。
曼哈顿距离(或出租车距离)是两个物体坐标差的绝对值之和。因此,如果给定两个点(1, 1)和(5, 4),则曼哈顿距离为 |1-5| + |1-4| = 4 + 3 = 7。
我们可以使用这个距离度量来检测一个物体是否异常 远离 其他所有物体。在本食谱中,我们将使用曼哈顿距离来检测离群值。计算仅涉及加法和减法,因此,它在处理大量数据时表现异常优异。
准备工作
创建一个由逗号分隔的点列表。我们将计算这些点与测试点之间的最小距离:
$ cat input.csv
0,0
10,0
0,10
10,10
5,5
如何做...
创建一个新文件,我们将其命名为 Main.hs
,并执行以下步骤:
-
导入 CSV 和 List 包:
import Text.CSV (parseCSV)
-
读取以下点:
main :: IO () main = do let fileName = "input.csv" input <- readFile fileName let csv = parseCSV fileName input
-
将数据表示为浮动点数的列表:
let points = either (\e -> []) (map toPoint . myFilter) csv
-
定义几个点来测试该函数:
let test1 = [2,1] let test2 = [-10,-10]
-
计算每个点的曼哈顿距离并找到最小的结果:
if (not.null) points then do print $ minimum $ map (manhattanDist test1) points print $ minimum $ map (manhattanDist test2) points else putStrLn "Error: no points to compare"
-
创建一个辅助函数将字符串列表转换为浮动点数列表:
toPoint record = map (read :: String -> Float) record
-
计算两个点之间的曼哈顿距离:
manhattanDist p1 p2 = sum $ zipWith (\x y -> abs (x - y)) p1 p2
-
过滤掉尺寸不正确的记录:
myFilter = filter (\x -> length x == 2)
-
输出将是测试点与点列表之间的最短距离:
$ runhaskell Main.hs 3.0 20.0
另请参阅
如果该距离与传统几何空间的距离更加接近,那么请阅读下一个食谱 计算欧几里得距离。
计算欧几里得距离
定义两个项目之间的距离允许我们轻松解释聚类和模式。欧氏距离是最自然的几何距离之一,它使用勾股定理来计算两个项目之间的距离,类似于使用物理尺子测量距离。
我们可以使用这个距离度量来检测一个项目是否与其他所有项目相隔甚远。在这个示例中,我们将使用欧氏距离检测异常值。与曼哈顿距离测量相比,它稍微更消耗计算资源,因为涉及乘法和平方根运算;然而,根据数据集的不同,它可能提供更准确的结果。
准备工作
创建一个逗号分隔的点列表。我们将计算这些点与测试点之间的最小距离。
$ cat input.csv
0,0
10,0
0,10
10,10
5,5
如何操作...
创建一个名为Main.hs
的新文件,并执行以下步骤:
-
导入 CSV 和 List 包:
import Text.CSV (parseCSV)
-
读取以下点:
main :: IO () main = do let fileName = "input.csv" input <- readFile fileName let csv = parseCSV fileName input
-
将数据表示为浮点数列表:
let points = either (\e -> []) (map toPoint . myFilter) csv
-
定义一对点以测试该函数:
let test1 = [2,1] let test2 = [-10,-10]
-
在每个点上计算欧氏距离并找到最小结果:
if (not.null) points then do print $ minimum $ map (euclidianDist test1) points print $ minimum $ map (euclidianDist test2) points else putStrLn "Error: no points to compare"
-
创建一个辅助函数将字符串列表转换为浮点数列表:
toPoint record = map (read String -> Float) record
-
计算两点之间的欧氏距离:
euclidianDist p1 p2 = sqrt $ sum $ zipWith (\x y -> (x - y)²) p1 p2
-
过滤掉尺寸不正确的记录:
myFilter = filter (\x -> length x == 2)
-
输出将是测试点与点列表之间的最短距离:
$ runhaskell Main.hs 2.236068 14.142136
参见
如果需要更高效的距离计算,则查看前一个示例,计算曼哈顿距离。
使用皮尔逊相关系数比较缩放数据
另一种衡量两个项目相关性的方法是检查它们各自的趋势。例如,显示上升趋势的两个项目更密切相关。同样,显示下降趋势的两个项目也密切相关。为简化算法,我们只考虑线性趋势。这种相关性计算称为皮尔逊相关系数。系数越接近零,两个数据集的相关性就越低。
对于样本,皮尔逊相关系数的计算公式如下:
如何操作...
创建一个名为Main.hs
的新文件,并执行以下步骤:
-
实现
main
以计算两个数字列表之间的相关系数:main :: IO () main = do let d1 = [3,3,3,4,4,4,5,5,5] let d2 = [1,1,2,2,3,4,4,5,5] let r = pearson d1 d2 print r
-
定义计算皮尔逊系数的函数:
pearson xs ys = (n * sumXY - sumX * sumY) / sqrt ( (n * sumX2 - sumX*sumX) * (n * sumY2 - sumY*sumY) ) where n = fromIntegral (length xs) sumX = sum xs sumY = sum ys sumX2 = sum $ zipWith (*) xs xs sumY2 = sum $ zipWith (*) ys ys sumXY = sum $ zipWith (*) xs ys
-
运行代码以打印系数。
$ runhaskell Main.hs 0.9128709291752768
工作原理如下...
皮尔逊相关系数衡量的是两个变量之间的线性关系程度。这个系数的大小描述了变量之间的相关程度。如果为正,表示两个变量一起变化;如果为负,表示一个变量增加时,另一个变量减少。
使用余弦相似度比较稀疏数据
当数据集有多个空字段时,使用曼哈顿距离或欧几里得距离进行比较可能会导致偏差结果。余弦相似度衡量的是两个向量之间的方向相似度。例如,向量(82, 86)和(86, 82)本质上指向相同的方向。实际上,它们的余弦相似度等同于(41, 43)和(43, 41)之间的余弦相似度。余弦相似度为 1 时,表示向量指向完全相同的方向,而为 0 时,表示向量彼此完全正交。
只要两个向量之间的角度相等,它们的余弦相似度就是相等的。在这种情况下,应用曼哈顿距离或欧几里得距离等距离度量会导致两组数据之间产生显著的差异。
两个向量之间的余弦相似度是两个向量的点积除以它们各自的模长的乘积。
如何操作...
创建一个新的文件,我们将其命名为Main.hs
,并执行以下步骤:
-
实现
main
以计算两个数字列表之间的余弦相似度。main :: IO () main = do let d1 = [3.5, 2, 0, 4.5, 5, 1.5, 2.5, 2] let d2 = [ 3, 0, 0, 5, 4, 2.5, 3, 0]
-
计算余弦相似度。
let similarity = dot d1 d2 / (eLen d1 * eLen d2) print similarity
-
定义点积和欧几里得长度的辅助函数。
dot a b = sum $ zipWith (*) a b eLen a = sqrt $ dot a a
-
运行代码以打印余弦相似度。
$ runhaskell Main.hs 0.924679432210068
另见
如果数据集不是稀疏的,考虑使用曼哈顿距离或欧几里得距离度量,详细内容见配方计算曼哈顿距离和计算欧几里得距离。
第三章:文字的科学
本章将介绍以下食谱:
-
以另一种进制显示数字
-
从另一种进制读取数字
-
使用 Data.ByteString 查找子字符串
-
使用 Boyer–Moore–Horspool 算法搜索字符串
-
使用 Rabin-Karp 算法搜索字符串
-
按行、单词或任意标记拆分字符串
-
查找最长公共子序列
-
计算语音编码
-
计算两个字符串之间的编辑距离
-
计算两个字符串之间的 Jaro–Winkler 距离
-
查找一个编辑距离内的字符串
-
使用编辑距离修正拼写错误
介绍
可以在大量单词的语料库上使用许多有趣的分析技术。无论是分析句子的结构还是书籍的内容,这些食谱将为我们介绍一些有用的工具。
在进行数据分析时,处理字符串的最常见函数之一是子字符串查找和编辑距离计算。由于数字通常出现在文本语料库中,本章将首先展示如何将数字表示为字符串,以任意进制显示。接着我们将介绍几种字符串搜索算法,并专注于提取文本,研究单词的使用方式以及它们如何组合在一起。
给定本节提供的一组简单工具,可以构建许多实际应用。例如,在最后一个食谱中,我们将演示如何纠正拼写错误。我们如何使用这些算法完全取决于我们的创造力,但至少有这些工具可用是一个很好的开始。
以另一种进制显示数字
字符串是表示不同进制数字的一种自然方式,因为字母被当作数字使用。本食谱将告诉我们如何将数字转换为一个字符串,并作为输出打印出来。
如何实现…
-
我们需要导入以下两个函数:
import Data.Char (intToDigit, chr, ord) import Numeric (showIntAtBase)
-
定义一个函数,以表示某个进制的数字,定义如下:
n 'inBase' b = showIntAtBase b numToLetter n ""
-
定义数字和字母之间的映射,用于表示大于九的数字,如下所示:
numToLetter :: Int -> Char numToLetter n | n < 10 = intToDigit n | otherwise = chr (ord 'a' n – 10)
-
使用以下代码片段打印结果:
main :: IO () main = do putStrLn $ 8 'inBase' 12 putStrLn $ 10 'inBase' 12 putStrLn $ 12 'inBase' 12 putStrLn $ 47 'inBase' 12
-
以下是运行代码时打印的输出:
$ runhaskell Main.hs 8 a 10 3b
它是如何工作的…
showIntAtBase
函数接收一个进制、期望的数字和数字到可打印数字的映射。我们按照以下顺序排列数字:0, 1, 2, 3, 4, 5, 6, 7, 8, 9, a, b, c, d, e, f,依此类推,直到 36 个字符。将这些组合在一起,我们就得到了一个方便的方式,可以将十进制数字表示为任意进制。
另见
要将表示数字的字符串从另一种进制读取为十进制整数,请参阅从另一种进制读取数字食谱。
从另一个进制读取数字
十进制、二进制和十六进制是广泛使用的数字系统,通常使用字符串表示。此方法将展示如何将任意进制的数字字符串转换为十进制整数。我们使用readInt
函数,它是前一个方法中描述的showIntAtBase
函数的双重。
如何实现...
-
导入
readInt
以及以下的字符操作函数,如下所示:import Data.Char (ord, digitToInt, isDigit) import Numeric (readInt)
-
定义一个函数,将表示某一特定进制的字符串转换为十进制整数,如下所示:
str 'base' b = readInt b isValidDigit letterToNum str
-
定义字母和数字之间的映射关系,以处理较大的数字,如以下代码片段所示:
letterToNum :: Char -> Int letterToNum d | isDigit d = digitToInt d | otherwise = ord d - ord 'a' + 10 isValidDigit :: Char -> Int isValidDigit d = letterToNum d >= 0
-
使用以下代码行输出结果:
main :: IO () main = do print $ "8" 'base' 12 print $ "a" 'base' 12 print $ "10" 'base' 12 print $ "3b" 'base' 12
-
输出结果如下所示:
[(8,"")] [(10,"")] [(12,"")] [(47,"")]
它是如何工作的...
readInt
函数读取一个无符号整数值并将其转换为指定的进制。它的第一个参数是进制,第二个参数是有效字符,第三个参数是字符到数字的映射。我们将数字按以下顺序排列:0,1,2,3,4,5,6,7,8,9,a,b,c,d,e,f,依此类推,直到 36 个字符。把这些结合起来,我们就得到了一种方便的方法,可以将任意进制的数字字符串转换为十进制数字。
提示
该方法假定传入base
函数的字符串有效以进行转换。进一步的错误检查是必要的,以确保错误输入(如"a" 'base' 4
)不会产生结果。
另见
要执行反向操作,请参阅在另一进制中显示数字的方法。
使用Data.ByteString
搜索子字符串
搜索一个字符串在另一个字符串中的位置有很多算法。这个方法将使用Data.ByteString
库中的现有breakSubstring
函数来完成大部分繁重的工作。
ByteString
文档通过声明以下内容来确立其优点:
"[ByteString 是]一种高效的字节向量实现,使用打包的 Word8 数组,适用于高性能用途,无论是大数据量,还是高速要求。字节向量被编码为严格的 Word8 字节数组,保存在 ForeignPtr 中,并可以在 C 和 Haskell 之间轻松传递。"
更多信息和文档可以在hackage.haskell.org/package/bytestring/docs/Data-ByteString.html
的包网页上获取。
如何实现...
-
导入
breakSubstring
函数以及Data.ByteString.Char8
包,如下所示:import Data.ByteString (breakSubstring) import qualified Data.ByteString.Char8 as C
-
将字符串打包为
ByteString
,并将其传递给breakSubstring
,其类型为:ByteString -> ByteString -> (ByteString, ByteString)
。然后确定是否找到该字符串:substringFound :: String -> String -> Bool substringFound query str = (not . C.null . snd) $ breakSubstring (C.pack query) (C.pack str)
-
在
main
中尝试以下测试:main = do print $ substringFound "scraf" "swedish scraf mafia" print $ substringFound "flute" "swedish scraf mafia"
-
执行
main
将输出以下结果:True False
它是如何工作的...
breakSubstring
函数递归地检查模式是否是字符串的前缀。为了懒惰地查找字符串的首次出现,我们可以调用 snd (breakSubstring pat str)
。
还有更多……
另一种优雅的快速查找子字符串的方法是使用 Data.List
和 Data.ByteString
提供的 isInfixOf
函数。此外,我们还可以使用 OverloadedStrings
语言扩展来去除冗余,如下所示的代码片段所示:
{-# LANGUAGE OverloadedStrings #-}
import Data.ByteString (isInfixOf)
main = do
print $ isInfixOf "scraf" "swedish scraf mafia"
print $ isInfixOf "flute" "swedish scraf mafia"
另见
根据我们要查找的模式的长度和整个字符串的长度,其他算法可能提供更好的性能。有关更多细节,请参阅 使用 Boyer-Moore-Horspool 算法搜索字符串 和 使用 Rabin-Karp 算法搜索字符串 的食谱。
使用 Boyer-Moore-Horspool 算法搜索字符串
在字符串中查找模式时,我们将模式称为 针,将整个文本称为 干草堆。本食谱中实现的 Horspool 字符串搜索算法对于几乎所有模式长度和字母表大小都表现良好,但对于大字母表大小和大针模式尺寸尤为理想。可以通过访问以下 URL 查找到经验基准:
orion.lcg.ufrj.br/Dr.Dobbs/books/book5/chap10.htm
通过对查询进行预处理,该算法能够有效地跳过冗余的比较。在本食谱中,我们将实现一个简化版的 Horspool 算法,它在平均最佳情况下与 Boyer-Moore 算法相同,且由于开销较小,受益于更小的开销成本,但在极少数情况下,算法执行过多匹配时,可能会遇到与朴素搜索相同的最坏运行时间。只有在接受额外的预处理时间和空间时,才应使用 Boyer-Moore 算法。
如何操作……
-
我们将使用以下几个
Data.Map
函数:import Data.Map (fromList, (!), findWithDefault)
-
为了方便,按如下方式定义表示字符索引的元组:
indexMap xs = fromList $ zip [0..] xs revIndexMap xs = fromList $ zip (reverse xs) [0..]
-
定义搜索算法,使用递归的
bmh'
函数如下:bmh :: Ord a => [a] -> [a] -> Maybe Int bmh pat xs = bmh' (length pat - 1) (reverse pat) xs pat
-
递归地在当前索引中查找模式,直到索引超过字符串的长度,如下代码片段所示:
bmh' :: Ord a => Int -> [a] -> [a] -> [a] -> Maybe Int bmh' n [] xs pat = Just (n + 1) bmh' n (p:ps) xs pat | n >= length xs = Nothing | p == (indexMap xs) ! n = bmh' (n - 1) ps xs pat | otherwise = bmh' (n + findWithDefault (length pat) (sMap ! n) pMap) (reverse pat) xs pat where sMap = indexMap xs pMap = revIndexMap pat
-
按如下方式测试该函数:
main :: IO () main = print $ bmh "Wor" "Hello World"
-
以下打印输出显示匹配子字符串的第一个索引:
Just 6
它是如何工作的……
该算法通过一个移动窗口将目标模式与文本进行比较。效率来自于移动窗口如何快速地从左到右在文本中移动。在 Horspool 算法中,查询会从右到左逐个字符与当前窗口进行比较,且窗口在最佳情况下按查询的大小进行移动。
另一版本的 Horspool 算法,由 Remco Niemeijer 设计,可以在bonsaicode.wordpress.com/2009/08/29/programming-praxis-string-search-boyer-moore
找到。
还有更多...
Boyer-Moore 算法确保在最坏情况下运行更快,但也会有稍微多一些的初始开销。请参考以下命令,使用Data.ByteString.Search
包中的 Boyer-Moore 算法:
$ cabal install stringsearch
导入以下库:
import Data.ByteString.Search
import qualified Data.ByteString.Char8 as C
向indices
函数提供两个ByteString
类型来运行搜索,方法如下:
main = print $ indices (C.pack "abc") (C.pack "bdeabcdabc")
这将打印出以下索引:
[3,7]
通过基准测试这个库的性能,我们可以看到较长的搜索针确实能提高运行时间。我们修改代码,通过一个名为big.txt
的文件在巨大的单词语料库中搜索多个针。这里,我们使用deepseq
函数强制评估,这样 Haskell 的惰性特性就不会忽略它,如下面的代码所示:
shortNeedles = ["abc", "cba"]
longNeedles = ["very big words", "some long string"]
main = do
corpus <- BS.readFile "big.txt"
map (\x -> (not.null) (indices x corpus)) shortNeedles
'deepseq' return ()
我们可以使用特别的运行时系统(RTS)控制编译这段代码,以便轻松进行性能分析,方法如下:
$ ghc -O2 Main.hs –rtsopts
$ ./Main +RTS -sstder
我们使用来自norvig.com/big.txt
的文本作为我们的语料库。搜索 25 个长针大约需要 0.06 秒;然而,搜索 25 个短针则需要较慢的 0.19 秒。
另请参见
要了解另一种高效的字符串搜索算法,请参考使用 Rabin-Karp 算法搜索字符串的示例。
使用 Rabin-Karp 算法搜索字符串
Rabin-Karp 算法通过将模式的唯一表示与一个滑动窗口进行匹配,来在文本中查找模式。这个唯一表示或哈希值是通过将字符串视为一个数字,并用 26 或更大的任意进制表示来计算的。
Rabin-Karp 的优势在于可以在干草堆中搜索多个针。仅搜索一个字符串效率并不高。经过初步的语料库预处理后,算法可以快速找到匹配项。
准备就绪
从 Cabal 安装Data.ByteString.Search
库,方法如下:
$ cabal install stringsearch
如何实现...
-
使用
OverloadedStrings
语言扩展来便于我们代码中的ByteString
操作,方法如下。它本质上允许字符串具有多态行为,因此当需要时,GHC 编译器可以推断它为ByteString
类型:{-# LANGUAGE OverloadedStrings #-}
-
导入 Rabin-Karp 算法,方法如下:
import Data.ByteString.Search.KarpRabin (indicesOfAny) import qualified Data.ByteString as BS
-
定义几个要查找的模式,并从
big.txt
文件中获取语料库,如下面的代码片段所示:main = do let needles = [ "preparing to go away" , "is some letter of recommendation"] haystack <- BS.readFile "big.txt"
-
运行 Rabin-Karp 算法,处理所有的搜索模式,方法如下:
print $ indicesOfAny needles haystack
-
代码将打印出每个针的所有索引,作为一个元组列表。元组的第一个元素是针在干草堆中的位置,第二个元素是针的索引列表。在我们的示例中,我们找到了“准备离开”的一个实例和“某封推荐信”的两个实例。
$ runhaskell Main.hs [(3738968,[1]),(5632846,[0]),(5714386,[0])]
它是如何工作的...
在 Rabin-Karp 算法中,一个固定窗口从左到右移动,比较唯一的哈希值,以便高效比较。哈希函数将字符串转换为其数字表示。以下是将字符串转换为以 256 为底的数字的示例:"hello" = h' * b⁴ + e' * b³ + l' * b² + l' * b¹ + o' * b⁰(结果为 448378203247),其中每个字母h' = ord h
(结果为 104),以此类推。
另请参见
要了解另一种高效的字符串搜索算法,请参见使用 Boyer-Moore-Horspool 算法搜索字符串的相关配方。
按行、按单词或按任意标记拆分字符串
有用的数据通常被分隔符(如逗号或空格)夹杂其中,因此字符串拆分对于大多数数据分析任务至关重要。
准备中
创建一个类似下面的input.txt
文件:
$ cat input.txt
first line
second line
words are split by space
comma,separated,values
or any delimiter you want
使用 Cabal 按照如下方式安装split
包:
$ cabal install split
如何实现...
-
我们所需要的唯一函数是
splitOn
,它按如下方式导入:import Data.List.Split (splitOn)
-
首先,我们将字符串拆分成行,代码示例如下:
main = do input <- readFile "input.txt" let ls = lines input print $ ls
-
这些行将按如下方式以列表形式打印:
[ "first line","second line" , "words are split by space" , "comma,separated,values" , "or any delimiter you want"]
-
接下来,我们按照如下方式在空格处拆分字符串:
let ws = words $ ls !! 2 print ws
-
单词将按如下方式以列表形式打印:
["words","are","split","by","space"]
-
接下来,我们展示如何使用以下代码行在任意值上拆分字符串:
let cs = splitOn "," $ ls !! 3 print cs
-
这些值将按逗号分隔,具体如下:
["comma","separated","values"]
-
最后,我们展示如何按照如下代码片段进行多字母拆分:
let ds = splitOn "an" $ ls !! 4 print ds
-
输出结果如下:
["or any d","limit","r you want"]
查找最长公共子序列
比较字符串相似性的一种方法是找出它们的最长公共子序列。这在查找数据变异之间的差异时非常有用,例如源代码或基因组序列。
字符串的子序列是从原字符串中删除零个或多个索引后的字符串。因此,“BITCOIN”的一些可能子序列可以是“ITCOIN”,“TON”,“BIN”,甚至是“BITCOIN”本身,如下图所示:
最长公共子序列正如其名,是指两个字符串中最长的公共子序列。例如,"find the lights"和"there are four lights"的最长公共子序列是"the lights"。
准备中
从 Cabal 安装data-memocombinators
包。这个包可以帮助我们最小化冗余计算,从而提升运行时效率,具体如下:
$ cabal install data-memocombinators
如何实现...
-
我们需要的唯一导入包是这个方便的包,用于轻松支持记忆化:
import qualified Data.MemoCombinators as Memo
-
创建一个方便的函数,以便对接收两个字符串参数的函数进行记忆化处理,代码示例如下:
memoize :: (String -> String -> r) -> String -> String -> r memoize = Memo.memo2 (Memo.list Memo.char) (Memo.list Memo.char)
-
定义最大公共子序列函数,如下所示:
lcs :: String -> String -> String lcs = memoize lcs' where lcs' xs'@(x:xs) ys'@(y:ys) | x == y = x : lcs xs ys | otherwise = longer (lcs xs' ys) (lcs xs ys') lcs' _ _ = []
-
在内部,定义一个返回较长字符串长度的函数。
longer as bs | length as > length bs = as | otherwise = bs
-
按照如下方式在两个字符串上运行该函数。
main :: IO () main = do let xs = "find the lights" let ys = "there are four lights" print $ lcs xs ys
-
以下是两个字符串之间的最长公共子序列:
"the lights"
它是如何工作的...
该算法是初步实现的,已在递归调用中添加了记忆化。如果列表的前两个项相同,则最长公共子序列是对列表剩余部分应用的lcs
函数。否则,最长公共子序列是两个可能性中较长的一个。
直观地说,当两个字符串的长度仅为 10 个字符时,这个算法会停滞不前。由于该代码分解为多个相同的子问题,我们可以轻松使用一个简单的memoize
函数来记住已经计算过的值,从而大幅提高运行时间。
计算语音编码
如果我们处理的是英语单词的语料库,那么我们可以将它们按语音编码进行分类,以查看它们的发音有多相似。语音编码适用于任何字母字符串,而不仅仅是实际的单词。我们将使用Text.PhoneticCode
包来计算 Soundex 和 Phoneix 语音编码。包文档可以在 Hackage 上找到,网址是hackage.haskell.org/package/phonetic-code
。
准备工作
按照以下方式从 Cabal 安装语音编码库:
$ cabal install phonetic-code
如何实现...
-
按如下方式导入语音编码函数:
import Text.PhoneticCode.Soundex (soundexNARA, soundexSimple) import Text.PhoneticCode.Phonix (phonix)
-
按如下方式定义一个相似发音的单词列表:
ws = ["haskell", "hackle", "haggle", "hassle"]
-
按照以下代码片段测试这些单词的语音编码:
main :: IO () main = do print $ map soundexNARA ws print $ map soundexSimple ws print $ map phonix ws
-
输出将按以下方式打印:
$ runhaskell Main.hs ["H240","H240","H240","H240"] ["H240","H240","H240","H240"] ["H82","H2","H2","H8"]
注意phonix
如何比soundex
产生更精细的分类。
它是如何工作的...
算法基于启发式的英语语言相关模式执行简单的字符串操作。
还有更多内容...
Metaphone 算法是 Soundex 算法的改进版,您可以在aspell.net/metaphone
找到它。
计算编辑距离
编辑距离或 Levenshtein 距离是将一个字符串转换为另一个字符串所需的最少简单字符串操作次数。在这个方案中,我们将只基于字符的插入、删除和替换来计算编辑距离。
准备工作
查看下图中的方程,该方程来自维基百科关于 Levenshtein 距离的文章(en.wikipedia.org/wiki/Levenshtein_distance
):
在这里,a和b是两个字符串,而 i 和 j 是表示它们长度的数字。
Haskell 代码将直接翻译为这个数学公式。
同样,从 Cabal 安装data-memocombinators
包。这可以帮助我们减少冗余计算,从而提升运行时间。
$ cabal install data-memocombinators
如何实现...
-
我们需要的唯一导入是能够轻松地使用以下代码行对函数进行记忆化:
import qualified Data.MemoCombinators as Memo
-
使用以下代码片段精确地定义 Levenshtein 距离函数,正如公式中所描述的那样:
lev :: Eq a => [a] -> [a] -> Int lev a b = levM (length a) (length b) where levM = memoize lev' lev' i j | min i j == 0 = max i j | otherwise = minimum [ ( 1 + levM (i-1) j ) , ( 1 + levM i (j-1) ) , ( ind i j + levM (i-1) (j-1) ) ]
-
定义一个指示函数,如果字符不匹配则返回 1。
ind i j | a !! (i-1) == b !! (j-1) = 0 | otherwise = 1
-
创建一个便利函数,用于启用对接受两个字符串参数的函数进行记忆化:
memoize = Memo.memo2 (Memo.integral) (Memo.integral)
-
打印出两个字符串之间的编辑距离:
main = print $ lev "mercury" "sylvester"
-
结果如下所示:
$ runhaskell Main.hs 8
它是如何工作的...
该算法递归地尝试所有的删除、插入和替换,并找到从一个字符串到另一个字符串的最小距离。
另见
另一种衡量方法在 计算两个字符串之间的 Jaro-Winkler 距离 食谱中有所描述。
计算两个字符串之间的 Jaro-Winkler 距离
Jaro-Winkler 距离衡量字符串相似度,表示为一个介于 0 和 1 之间的实数。值为 0 表示没有相似性,值为 1 表示完全匹配。
准备就绪
该函数背后的算法来源于 Wikipedia 上关于 Jaro-Winkler 距离的文章中展示的以下数学公式:en.wikipedia.org/wiki/Jaro%E2%80%93Winkler_distance
:
在前面的公式中,以下是所使用变量的表示形式:
-
s1 是第一个字符串。
-
s2 是第二个字符串。
-
m 是在最大为较长字符串一半的距离内匹配的字符数量。这些称为匹配字符。
-
t 是不在同一索引处的匹配字符的一半。换句话说,它是交换位置的字符数的一半。
如何实现...
-
我们需要访问
elemIndices
函数,它被如下导入:import Data.List (elemIndices)
-
基于以下公式定义 Jaro-Winkler 函数:
jaro :: Eq a => [a] -> [a] -> Double jaro s1 s2 | m == 0 = 0.0 | otherwise = (1/3) * (m/ls1 + m/ls2 + (m-t)/m)
-
定义所使用的变量,如下所示:
where ls1 = toDouble $ length s1 ls2 = toDouble $ length s2 m' = matching s1 s2 d d = fromIntegral $ max (length s1) (length s2) 'div' 2 – 1 m = toDouble m' t = toDouble $ (m' - matching s1 s2 0) 'div' 2
-
定义一个辅助函数,将整数转换为
Double
类型:toDouble :: Integral a => a -> Double toDouble n = (fromIntegral n) :: Double
-
定义一个辅助函数,用于查找在指定距离内匹配的字符数量,如下所示的代码片段:
matching :: Eq a => [a] -> [a] -> Int -> Int matching s1 s2 d = length $ filter (\(c,i) -> not (null (matches s2 c i d))) (zip s1 [0..])
-
定义一个辅助函数,用于查找从指定索引处某个字符开始的匹配字符数量,如下所示:
matches :: Eq a => [a] -> a -> Int -> Int -> [Int] matches str c i d = filter (<= d) $ map (dist i) (elemIndices c str) where dist a b = abs $ a - b
-
通过打印出一些示例来测试算法,如下所示:
main = do print $ jaro "marisa" "magical" print $ jaro "haskell" "hackage"
-
相似度按如下方式打印,意味着 "marisa" 更接近 "magical" 而不是 "haskell" 接近 "hackage"。
$ runhaskell Main.hs 0.746031746031746 0.7142857142857142
另见
另一种计算字符串相似度的方法,在之前的食谱 计算编辑距离 中有定义。
查找一个编辑距离内的字符串
本食谱将展示如何查找与指定字符串具有一个编辑距离的字符串。该函数可用于纠正拼写。
准备就绪
本食谱中的算法在很大程度上基于 Peter Norvig 在 norvig.com/spell-correct.html
上描述的拼写更正算法。查看并研究那里实现的 edits1
Python 函数。
如何实现...
-
导入如下所示的几个字符和列表函数:
import Data.Char (toLower) import Data.List (group, sort)
-
定义一个函数,用于返回与指定字符串只有一个编辑距离的字符串,如下所示的代码片段:
edits1 :: String -> [String] edits1 word = unique $ deletes ++ transposes ++ replaces ++ inserts where splits = [ (take i word', drop i word') | i <- [0..length word']]
-
创建一个删除一个字符的字符串列表,如下所示:
deletes = [ a ++ (tail b) | (a,b) <- splits, (not.null) b]
-
创建一个交换两个字符的字符串列表,如下所示:
transposes = [a ++ [b!!1] ++ [head b] ++ (drop 2 b) | (a,b) <- splits, length b > 1 ]
-
创建一个字符串列表,其中一个字符被字母表中的另一个字母替换,如下所示:
replaces = [ a ++ [c] ++ (drop 1 b) | (a,b) <- splits , c <- alphabet , (not.null) b ]
-
创建一个字符串列表,其中一个字符在任何位置被插入,如下所示:
inserts = [a ++ [c] ++ b | (a,b) <- splits , c <- alphabet ]
-
定义字母表和一个辅助函数将字符串转换为小写,如下所示:
alphabet = ['a'..'z'] word' = map toLower word
-
定义一个辅助函数从列表中获取唯一元素,如下所示:
unique :: [String] -> [String] unique = map head.group.sort
-
打印出所有与以下字符串编辑距离为一的可能字符串,如下所示:
main = print $ edits1 "hi"
结果如下所示:
["ahi","ai","bhi","bi","chi","ci","dhi","di","ehi","ei","fhi","fi","ghi","gi","h","ha","hai","hb","hbi","hc","hci","hd","hdi","he","hei","hf","hfi","hg","hgi","hh","hhi","hi","hia","hib","hic","hid","hie","hif","hig","hih","hii","hij","hik","hil","him","hin","hio","hip","hiq","hir","his","hit","hiu","hiv","hiw","hix","hiy","hiz","hj","hji","hk","hki","hl","hli","hm","hmi","hn","hni","ho","hoi","hp","hpi","hq","hqi","hr","hri","hs","hsi","ht","hti","hu","hui","hv","hvi","hw","hwi","hx","hxi","hy","hyi","hz","hzi","i","ih","ihi","ii","jhi","ji","khi","ki","lhi","li","mhi","mi","nhi","ni","ohi","oi","phi","pi","qhi","qi","rhi","ri","shi","si","thi","ti","uhi","ui","vhi","vi","whi","wi","xhi","xi","yhi","yi","zhi","zi"]
更直观地,我们创建了一个仅通过 1 次插入、删除、替换或交换不同的单词邻域。以下图试图展示这个邻域:
还有更多...
我们可以递归地应用edit1
来查找任意编辑距离的字符串。然而,对于* n *大于三的值,这将需要不可接受的长时间。在以下代码中,edits1
'
是一个函数,它接收字符串列表并生成所有编辑距离为一的字符串。然后在editsN
中,我们简单地按如下方式迭代应用edits1'
函数:
edits1' :: [String] -> [String]
edits1' ls = unique $ concat $ map edits1 ls
editsN :: String -> Int -> [String]
editsN word n = iterate edits1' (edits1 word) !! n
另见
这个函数在实现修正拼写错误方法中非常有用。
修正拼写错误
当收集人工提供的数据时,拼写错误可能悄悄进入。这个方法会使用 Peter Norvig 描述的简单启发式拼写检查器来纠正拼写错误,详情见norvig.com/spell-correct.html
。
这个方法只是机器学习中解决一个非常困难问题的一个思路。我们可以将其作为起点,或作为灵感去实现一个更强大的解决方案,取得更好的结果。
准备就绪
请参考 Norvig 的拼写纠正 Python 算法,位置在norvig.com/spell-correct.html
。
核心算法如下所示:
-
将原始文本转换为小写字母单词
-
计算所有单词的频率图
-
定义函数来生成所有编辑距离为一或二的字符串
-
查找拼写错误的所有可能候选项,通过查找在编辑距离为一或二以内的有效单词
-
最后,挑选出在训练语料库中出现频率最高的候选项
以下 Haskell 算法模仿了这段 Python 代码。
如何实现...
-
导入以下函数:
import Data.Char (isAlpha, isSpace, toLower) import Data.List (group, sort, maximumBy) import Data.Ord (comparing) import Data.Map (fromListWith, Map, member, (!))
-
定义一个函数来自动修正句子中每个单词的拼写:
autofix :: Map String Int -> String -> String autofix m sentence = unwords $ map (correct m) (words sentence)
-
从一段文本中提取单词。
getWords :: String -> [String] getWords str = words $ filter (\x -> isAlpha x || isSpace x) lower where lower = map toLower str
-
计算所提供单词的频率图,如下所示:
train :: [String] -> Map String Int train = fromListWith (+) . ('zip' repeat 1)
-
查找编辑距离为一的字符串,如下所示:
edits 1 :: String -> [String] edits1 word = unique $ deletes ++ transposes ++ replaces ++ inserts where splits = [ (take i word', drop i word') | i <- [0..length word']] deletes = [ a ++ (tail b) | (a,b) <- splits , (not.null) b ] transposes = [ a ++ [b !! 1] ++ [head b] ++ (drop 2 b) | (a,b) <- splits, length b > 1 ] replaces = [ a ++ [c] ++ (drop 1 b) | (a,b) <- splits, c <- alphabet , (not.null) b ] inserts = [a ++ [c] ++ b | (a,b) <- splits, c <- alphabet ] alphabet = ['a'..'z'] word' = map toLower word
-
查找编辑距离为二的单词:
knownEdits2 :: String -> Map String a -> [String] knownEdits2 word m = unique $ [ e2 | e1 <- edits1 word , e2 <- edits1 e1 , e2 'member' m]
-
定义一个辅助函数从列表中获取唯一元素,如下所示:
unique :: [String] -> [String] unique = map head.group.sort
-
从字符串列表中查找已知单词,如下所示:
known :: [String] -> Map String a -> [String] known ws m = filter ('member' m) ws
-
通过返回最常见的候选项来纠正拼写错误,如下所示:
correct :: Map String Int -> String -> String correct m word = maximumBy (comparing (m!)) candidates where candidates = head $ filter (not.null) [ known [word] m , known (edits1 word) m , knownEdits2 word m , [word] ]
-
从
big.txt
中收集常见文学作品中使用的已知单词列表。该文件可通过norvig.com/big.txt
访问,或者我们也可以自己创建。然后,按照以下方式测试拼写校正器:main :: IO () main = do rawText <- readFile "big.txt" let m = train $ getWords rawText let sentence = "such codez many hsakell very spel so korrect" print $ autofix m sentence
-
正确的拼写将如下所示:
$ runhaskell Main.hs "such code many haskell very spell so correct"
它是如何工作的...
该算法假设拼写错误发生在一个或两个编辑距离之内。它建立了一个已知单词的列表,该列表包含一个或两个编辑距离内的单词,并根据通过读取现实世界文本语料库生成的频率图返回最常用的单词。
还有更多内容...
该算法运行速度很快,但它非常简单。这段代码为实现拼写校正器提供了一个起点,但绝对不是最先进的技术。可以添加的一些改进包括并行化、缓存或设计更好的启发式方法。
另请参见
如需更深入分析edit1
函数,请参考在一个编辑距离内查找字符串的配方。
第四章 数据哈希
在本章中,我们将介绍以下食谱:
-
哈希原始数据类型
-
哈希自定义数据类型
-
运行流行的加密哈希函数
-
对文件运行加密校验和
-
在数据类型之间进行快速比较
-
使用高性能哈希表
-
使用 Google 的 CityHash 哈希函数对字符串进行哈希
-
计算位置坐标的 Geohash
-
使用布隆过滤器去除唯一项
-
运行 MurmurHash,一个简单但快速的哈希算法
-
使用感知哈希测量图像相似度
介绍
哈希是一种有损的方式,将对象表示为一个小而通常是固定长度的值。哈希数据使我们能够快速查找和轻松处理大量数据集。
哈希函数的输出被称为摘要。一个好的哈希函数的主要特性之一是它必须是确定性的,这意味着给定的输入必须始终产生相同的输出。有时,两个不同的输入可能最终产生相同的输出,我们称之为碰撞。仅凭哈希值,我们无法反转过程在合理的时间内重新发现原始对象。为了最小化碰撞的几率,哈希函数的另一个特性叫做均匀性。换句话说,每个输出出现的概率应该几乎相同。
我们将首先从输入生成一个简单的摘要。然后,在下一个食谱中,我们将对自定义数据类型运行哈希算法。
哈希的另一个重要应用是在加密学中。我们将介绍一些最流行的加密哈希算法,如 SHA-512。我们还将应用这些哈希对文件进行校验和计算,以确保文件完整性。
最后,我们将介绍许多非传统的哈希方法,包括 CityHash、GeoHashing、布隆过滤器、MurmurHash 和 pHash。
哈希原始数据类型
本食谱演示了如何在各种原始数据类型上使用简单的哈希函数。
准备工作
从 Cabal 安装Data.Hashable
包,如下所示:
$ cabal install hashable
如何操作……
-
使用以下行导入哈希函数:
import Data.Hashable
-
测试
hash
函数对字符串的作用,如下所示;该函数实际上是一个包装器,围绕着默认盐值的hashWithSalt
函数:main = do print $ hash "foo"
-
使用不同的初始盐值测试
hashWithSalt
函数,如下所示:print $ hashWithSalt 1 "foo" print $ hashWithSalt 2 "foo"
-
我们还可以如下对元组和列表进行哈希:
print $ hash [ (1 :: Int, "hello", True) , (0 :: Int, "goodbye", False) ]
-
注意以下输出中的前三个哈希尽管输入相同,却产生了不同的结果:
$ runhaskell Main.hs 7207853227093559468 367897294438771247 687941543139326482 6768682186886785615
它是如何工作的……
使用盐值进行哈希意味着在稍微修改数据后再应用哈希函数。就像我们在通过哈希函数处理输入之前“加盐”了一样。即使盐值稍有变化,也会产生完全不同的哈希摘要。
我们需要这种盐的概念来提高密码安全性。哈希函数对于相同的输入总是产生相同的输出,这既有好处也有坏处。对于所有主要的哈希算法,都有现成的彩虹表数据库,其中包含每个常用密码。如果一个具有登录系统服务的网站(例如 Packt Publishing)使用加密哈希存储密码,但没有使用盐,那么如果密码本身被认为是弱密码,它与明文密码没有区别。如果像 Packt Publishing 这样的服务在其加密哈希中使用盐(并且应该使用),那么它就增加了一层安全性,而彩虹表则变得无用。
还有更多……
之前的代码生成了一个字符串的哈希,但这个算法不限于字符串。以下数据类型也实现了 hashable
:
-
Bool
-
Char
-
Int
-
Int8
-
Int16
-
Int32
-
Int64
-
Word
-
Word8
-
Word16
-
Word32
-
Word64
-
ByteString
-
可哈希项的列表
-
哈希项的元组
-
也许是一个可哈希的项
另请参见
关于如何对自定义数据类型使用哈希函数,请参考 哈希一个自定义数据类型 这个配方。
哈希一个自定义数据类型
即使是自定义定义的数据类型也可以轻松进行哈希。当数据本身占用空间过大,无法直接管理时,处理哈希摘要通常非常有用。通过使用数据的摘要引用,我们可以轻松避免携带整个数据类型的开销。这在数据分析中尤其有用。
准备好
通过以下方式从 Cabal 安装 Data.Hashable
包:
$ cabal install hashable
如何实现……
-
使用 GHC 语言扩展
DeriveGeneric
自动定义我们自定义数据类型的哈希函数,如下所示:{-# LANGUAGE DeriveGeneric #-}
-
使用以下代码行导入相关包:
import GHC.Generics (Generic) import Data.Hashable
-
创建一个自定义数据类型,并让
GHC
自动定义其哈希实例,如下所示:data Point = Point Int Int deriving (Eq, Generic) instance Hashable Point
-
在
main
中,创建三个点。让其中两个相同,第三个不同,如以下代码片段所示:main = do let p1 = Point 1 1 let p2 = Point 1 1 let p3 = Point 3 5
-
打印相同点的哈希值,如下所示:
if p1 == p2 then putStrLn "p1 = p2" else putStrLn "p1 /= p2" if hash p1 == hash p2 then putStrLn "hash p1 = hash p2" else putStrLn "hash p1 /= hash p2"
-
打印不同点的哈希值,如下所示:
if p1 == p3 then putStrLn "p1 = p3" else putStrLn "p1 /= p3" if hash p1 == hash p3 then putStrLn "hash p1 = hash p3" else putStrLn "hash p1 /= hash p3"
-
输出将如下所示:
$ runhaskell Main.hs p1 = p2 hash p1 = hash p2 p1 /= p3 hash p1 /= hash p3
还有更多……
我们可以通过为 Hashable
提供实例来为自定义数据类型定义哈希函数。Hashable
实例只需要实现 hashWithSalt :: Int -> a -> Int
。为了帮助实现 hashWithSalt
,我们还提供了两个有用的函数:
-
使用盐对指针进行哈希操作,如以下代码片段所示:
hashPtrWithSalt :: Ptr a -- pointer to the data to hash -> Int -- length, in bytes -> Int -- salt -> IO Int -- hash value
-
使用盐对字节数组进行哈希操作,如以下代码片段所示:
hashByteArrayWithSalt :: ByteArray# -- data to hash -> Int -- offset, in bytes -> Int -- length, in bytes -> Int -- salt -> Int -- hash value
另请参见
要哈希一个内建的原始类型,请参考 哈希一个原始数据类型 这个配方。
运行流行的加密哈希函数
一个加密哈希函数具有特定的属性,使其与其他哈希函数不同。首先,从给定的哈希摘要输出生成可能的输入消息应该是不可行的,意味着在实践中解决这个问题必须耗费指数级的时间。
例如,如果哈希值产生了摘要66fc01ae071363ceaa4178848c2f6224
,那么原则上,发现用于生成摘要的内容应该是困难的。
在实践中,一些哈希函数比其他的更容易破解。例如,MD5 和 SHA-1 被认为很容易破解,不应再使用,但为了完整性,稍后会展示这两个哈希函数。关于 MD5 和 SHA-1 不安全的更多信息可以参考www.win.tue.nl/hashclash/rogue-ca
和www.schneier.com/blog/archives/2005/02/cryptanalysis_o.html
。
准备工作
从 Cabal 安装Crypto.Hash
包,如下所示:
$ cabal install cryptohash
如何操作……
-
按如下方式导入加密哈希函数库:
import Data.ByteString.Char8 (ByteString, pack) import Crypto.Hash
-
按如下方式通过明确关联数据类型来定义每个哈希函数:
skein512_512 :: ByteString -> Digest Skein512_512 skein512_512 bs = hash bs skein512_384 :: ByteString -> Digest Skein512_384 skein512_384 bs = hash bs skein512_256 :: ByteString -> Digest Skein512_256 skein512_256 bs = hash bs skein512_224 :: ByteString -> Digest Skein512_224 skein512_224 bs = hash bs skein256_256 :: ByteString -> Digest Skein256_256 skein256_256 bs = hash bs skein256_224 :: ByteString -> Digest Skein256_224 skein256_224 bs = hash bs sha3_512 :: ByteString -> Digest SHA3_512 sha3_512 bs = hash bs sha3_384 :: ByteString -> Digest SHA3_384 sha3_384 bs = hash bs sha3_256 :: ByteString -> Digest SHA3_256 sha3_256 bs = hash bs sha3_224 :: ByteString -> Digest SHA3_224 sha3_224 bs = hash bs tiger :: ByteString -> Digest Tiger tiger bs = hash bs whirlpool :: ByteString -> Digest Whirlpool whirlpool bs = hash bs ripemd160 :: ByteString -> Digest RIPEMD160 ripemd160 bs = hash bs sha512 :: ByteString -> Digest SHA512 sha512 bs = hash bs sha384 :: ByteString -> Digest SHA384 sha384 bs = hash bs sha256 :: ByteString -> Digest SHA256 sha256 bs = hash bs sha224 :: ByteString -> Digest SHA224 sha224 bs = hash bs sha1 :: ByteString -> Digest SHA1 sha1 bs = hash bs md5 :: ByteString -> Digest MD5 md5 bs = hash bs md4 :: ByteString -> Digest MD4 md4 bs = hash bs md2 :: ByteString -> Digest MD2 md2 bs = hash bs
-
按如下代码片段测试每个加密哈希函数在相同输入上的表现:
main = do let input = pack "haskell data analysis" putStrLn $ "Skein512_512: " ++ (show.skein512_512) input putStrLn $ "Skein512_384: " ++ (show.skein512_384) input putStrLn $ "Skein512_256: " ++ (show.skein512_256) input putStrLn $ "Skein512_224: " ++ (show.skein512_224) input putStrLn $ "Skein256_256: " ++ (show.skein256_256) input putStrLn $ "Skein256_224: " ++ (show.skein256_224) input putStrLn $ "SHA3_512: " ++ (show.sha3_512) input putStrLn $ "SHA3_384: " ++ (show.sha3_384) input putStrLn $ "SHA3_256: " ++ (show.sha3_256) input putStrLn $ "SHA3_224: " ++ (show.sha3_224) input putStrLn $ "Tiger: " ++ (show.tiger) input putStrLn $ "Whirlpool: " ++ (show.whirlpool) input putStrLn $ "RIPEMD160: " ++ (show.ripemd160) input putStrLn $ "SHA512: " ++ (show.sha512) input putStrLn $ "SHA384: " ++ (show.sha384) input putStrLn $ "SHA256: " ++ (show.sha256) input putStrLn $ "SHA224: " ++ (show.sha224) input putStrLn $ "SHA1: " ++ (show.sha1) input putStrLn $ "MD5: " ++ (show.md5) input putStrLn $ "MD4: " ++ (show.md4) input putStrLn $ "MD2: " ++ (show.md2) input
-
最终输出可在以下截图中看到:
$ runhaskell Main.hs
另请参见
若要在文件上运行这些加密哈希函数以执行完整性检查,请参考在文件上运行加密校验和的配方。
在文件上运行加密校验和
判断计算机上的文件是否与其他地方的文件不同的最有效方法之一是通过比较它们的加密哈希值。如果两个哈希值相等,虽然文件相等的可能性非常高,但由于碰撞的可能性,不能严格保证文件完全相同。
下载一个文件,例如从www.archlinux.org/download
下载 Arch Linux,最好确保其加密哈希值匹配。例如,看看以下截图:
上面的截图显示了截至 2014 年 5 月底 Arch Linux 下载的相应哈希值。
请注意同时提供了 MD5 和 SHA1 哈希值。本配方将展示如何在 Haskell 中计算这些哈希值,以确保数据的完整性。
我们将计算其源文件的 SHA256、SHA512 和 MD5 哈希值。
准备工作
从 Cabal 安装Crypto.Hash
包,如下所示:
$ cabal install cryptohash
如何操作……
创建一个名为Main.hs
的文件,并插入以下代码:
-
按如下方式导入相关的包:
import Crypto.Hash import qualified Data.ByteString as BS
-
按如下方式定义
MD5
哈希函数:md5 :: BS.ByteString -> Digest MD5 md5 bs = hash bs
-
按如下方式定义
SHA256
哈希函数:sha256 :: BS.ByteString -> Digest SHA256 sha256 bs = hash bs
-
按如下方式定义
SHA512
哈希函数:sha512 :: BS.ByteString -> Digest SHA512 sha512 bs = hash bs
-
使用
Data.ByteString
包提供的readFile
函数打开ByteString
类型的文件,如下所示:main = do byteStr <- BS.readFile "Main.hs"
-
按如下方式测试文件上的各种哈希值:
putStrLn $ "MD5: " ++ (show.md5) byteStr putStrLn $ "SHA256: " ++ (show.sha256) byteStr putStrLn $ "SHA512: " ++ (show.sha512) byteStr
-
以下是生成的输出:
$ runhaskell Main.hs MD5: 242334e552ae8ede926de9c164356d18 SHA256: 50364c25e0e9a835df726a056bd5370657f37d20aabc82e0b1719a343ab505d8 SHA512: 1ad6a9f8922b744c7e5a2d06bf603c267ca6becbf52b2b22f8e5a8e2d82fb52d87ef4a13c9a405b06986d5d19b170d0fd05328b8ae29f9d92ec0bca80f7b60e7
另请参见
若要在数据类型上应用加密哈希函数,请参考运行常见加密哈希函数的配方。
执行数据类型间的快速比较
StableName
包允许我们对任意数据类型建立常数时间的比较。Hackage 文档优雅地描述了这一点(hackage.haskell.org/package/base-4.7.0.0/docs/System-Mem-StableName.html
):
“稳定名称解决了以下问题:假设你想要用 Haskell 对象作为键来构建哈希表,但你希望使用指针相等性进行比较;可能是因为键非常大,哈希操作会很慢,或者是因为键的大小是无限的。我们不能使用对象的地址作为键来构建哈希表,因为对象会被垃圾收集器移动,这意味着每次垃圾回收后都需要重新哈希。”
如何操作……
-
按照以下方式导入内置的
StableName
包:import System.Mem.StableName
-
按照以下方式创建一个自定义数据类型:
data Point = Point [Int]
-
在
main
中,按如下方式定义两个点:main = do let p1 = Point [1..] let p2 = Point [2,4]
-
获取每个点的稳定名称,并使用以下命令集显示它:
sn1 <- makeStableName p1 sn2 <- makeStableName p2 print $ hashStableName sn1 print $ hashStableName sn2
-
注意以下结果,我们可以轻松获取任意数据类型的稳定名称:
$ runhaskell Main.hs 22 23
使用高性能哈希表
Haskell 已经提供了一个基于大小平衡二叉树的Data.Map
模块。还有一些优化更好的哈希表库,如unordered-containers
包中的Data.HashMap
。
例如,Data.Map
和Data.HashMap
都具有 O(log n)的插入和查找时间复杂度;然而,后者使用了较大的基数,因此在实际操作中,这些操作是常数时间。有关Data.HashMap
的更多文档可以在hackage.haskell.org/package/unordered-containers-0.2.4.0/docs/Data-HashMap-Lazy.html
找到。
在这个示例中,我们将使用 Hackage 上的 unordered-contains 库,创建一个将单词长度映射到该长度单词集合的映射。
准备就绪
下载一个大文本语料库并将文件命名为big.txt
,如下所示:
$ wget norvig.com/big.txt
使用 Cabal 安装Data.HashMap
包,方法如下:
$ cabal install unordered-containers
如何操作……
-
按照以下方式导入
HashMap
包:import Data.HashMap.Lazy import Data.Set (Set) import qualified Data.Set as Set
-
创建一个辅助函数来定义一个空的哈希映射,使用以下代码行:
emptyMap = empty :: HashMap Int (Set String)
-
使用以下代码片段定义一个函数,将单词插入哈希映射:
insertWord m w = insertWith append key val m where append new old = Set.union new old key = length w val = Set.singleton w
-
按照以下方式从映射中查找特定长度的所有单词:
wordsOfLength len m = Set.size(lookupDefault Set.empty len m )
-
使用以下代码行从文本语料库构建哈希映射:
constructMap text = foldl (\m w -> insertWord m w) emptyMap (words text)
-
阅读大型文本语料库,构建哈希映射,并打印每个长度单词的数量,代码片段如下:
main = do text <- readFile "big.txt" let m = constructMap text print [wordsOfLength s m | s <- [1..30]]
-
输出如下:
$ runhaskell Main.hs [59,385,1821,4173,7308,9806,11104,11503,10174,7948,5823,4024,2586,1597,987,625,416,269,219,139,115,78,51,50,27,14,17,15,11,7]
如果我们绘制数据图表,可以发现一个有趣的趋势,如下图所示:
工作原理……
有关该库的技术细节,请参见作者在以下博客文章中的说明:
blog.johantibell.com/2012/03/announcing-unordered-containers-02.html
使用 Google 的 CityHash 哈希函数处理字符串
Google 的 CityHash 哈希函数专为字符串哈希优化,但并不适用于密码学安全。CityHash 非常适合实现处理字符串的哈希表。我们将在本食谱中使用它来生成 64 位和 128 位的摘要。
准备就绪
如下所示,从 Cabal 安装 cityhash
包:
$ cabal install cityhash
如何做到…
-
如下所示导入相关包:
import Data.Digest.CityHash import Data.ByteString.Char8 (pack) import Data.Word (Word64) import Data.LargeWord (Word128)
-
使用以下代码片段测试不同的哈希函数在输入字符串上的效果:
main = do (pack str) (1 :: Word128) let str = "cityhash" print $ cityHash64 (pack str) print $ cityHash64WithSeed (pack str) (1 :: Word64) print $ cityHash64WithSeed (pack str) (2 :: Word64) print $ cityHash128 (pack str) print $ cityHash128WithSeed print $ cityHash128WithSeed (pack str) (2 :: Word128)
-
如下所示显示输出:
$ runhaskell Main.hs 11900721293443925155 10843914211836357278 12209340445019361150 116468032688941434670559074973810442908 218656848647432546431274347445469875003 45074952647722073214392556957268553766
它是如何工作的…
Google 在其博客公告中描述了该包,地址为 google-opensource.blogspot.com/2011/04/introducing-cityhash.html
,如下所示:
"我们方法的关键优势在于,大多数步骤至少包含两个独立的数学运算。现代 CPU 在这种类型的代码上表现最好。"
另请参见
要查看更通用的哈希函数,请参考 哈希原始数据类型 和 哈希自定义数据类型 这两个食谱。
计算位置坐标的 Geohash
Geohash 是一种实际的经纬度坐标编码。与典型的哈希函数不同,Geohash 在位置上有细微变化时,输出摘要也会发生小变化。Geohash 允许高效的邻近搜索,精度由摘要长度决定。
准备就绪
安装 Geohashing 库,如下所示:
$ cabal install geohash
如何做到…
-
如下所示导入
Geohash
库:import Data.Geohash
-
创建一个经纬度坐标对的地理哈希值,如下所示:
main = do let geohash1 = encode 10 (37.775, -122.419) putStrLn $ "geohash1 is " ++ (show geohash1)
-
使用以下代码片段显示地理哈希值:
case geohash1 of Just g -> putStrLn $ "decoding geohash1: " ++ (show.decode) g Nothing -> putStrLn "error encoding"
-
创建另一个相似的经纬度坐标对的地理哈希值,如下所示:
let geohash2 = encode 10 (37.175, -125.419) putStrLn $ "geohash2 is " ++ (show geohash2)
-
使用以下代码片段显示地理哈希值:
case geohash2 of Just g -> putStrLn $ "decoding geohash2: " ++ (show.decode) g Nothing -> putStrLn "error encoding"
-
输出如下所示。请注意,由于它们的相似性,地理哈希值似乎共享相同的前缀。
$ runhaskell Main.hs geohash1 is Just "9q8yyk9pqd" decoding geohash1: Just (37.775000631809235,-122.4189966917038) geohash2 is Just "9nwg6p88j6" decoding geohash2: Just (37.175001204013824,-125.4190045595169)
使用 Bloom 过滤器去除唯一项
Bloom 过滤器是一种抽象数据类型,用于测试某个项是否存在于集合中。与典型的哈希映射数据结构不同,Bloom 过滤器仅占用恒定的空间。它的优势在于处理数十亿数据时非常有效,例如 DNA 链条的字符串表示:"GATA"、"CTGCTA" 等。
在本食谱中,我们将使用 Bloom 过滤器尝试从列表中移除唯一的 DNA 链条。这通常是需要的,因为一个典型的 DNA 样本可能包含成千上万只出现一次的链条。Bloom 过滤器的主要缺点是可能会产生假阳性结果,即它可能错误地认为某个元素存在。尽管如此,假阴性是不可发生的:Bloom 过滤器永远不会错误地认为某个元素不存在,即使它实际存在。
准备就绪
如下所示,从 Cabal 导入 Bloom 过滤器包:
$ cabal install bloomfilter
如何做到…
-
如下所示导入 Bloom 过滤器包:
import Data.BloomFilter (fromListB, elemB, emptyB, insertB) import Data.BloomFilter.Hash (cheapHashes) import Data.Map (Map, empty, insertWith) import qualified Data.Map as Map
-
创建一个函数来移除列表中的唯一元素。首先检查每个项目是否存在于布隆过滤器中;如果存在,将其添加到哈希映射中。如果不存在,则将其添加到布隆过滤器中,代码示例如下:
removeUniques strands = foldl bloomMapCheck (emptyBloom, emptyMap) strands where emptyBloom = emptyB (cheapHashes 3) 1024 emptyMap = empty :: Map String Int bloomMapCheck (b, m) x | elemB x b = (b, insertWith (+) x 1 m) | otherwise = (insertB x b, m)
-
在几个 DNA 链示例上运行算法,如下所示:
main = do let strands = ["GAT", "GATC", "CGT", "GAT" , "GAT", "CGT", "GAT", "CGT"] print $ snd $ removeUniques strands
-
我们看到以下可能至少出现两次的串:
$ runhaskell Main.hs fromList [("CGT",2),("GAT",3)]
它是如何工作的…
布隆过滤器由几个哈希函数和一个初始化为零的数字列表组成。当将元素插入该数据结构时,会根据每个哈希函数计算哈希,并更新列表中相应的项。布隆过滤器的成员测试是通过计算输入的每个哈希函数并测试所有相应的列表元素是否都超过某个阈值来进行的。
例如,在前面的图示中,三个哈希函数会应用于每个输入。当计算x、y和z的哈希时,表示布隆过滤器的列表中的相应元素会增加。我们可以通过计算三个哈希并检查相应索引是否都达到所需值来确定w是否存在于此布隆过滤器中。在此情况下,w并不存在于布隆过滤器中。
运行 MurmurHash,一个简单但快速的哈希算法
有时,哈希函数的优先级应该是最大化其计算速度。MurmurHash 算法正是为此目的而存在。当处理大规模数据集时,速度至关重要。
提示
快速哈希算法也有其负面影响。如果哈希算法 A 比哈希算法 B 快 10 倍,那么用随机内容搜索时,找到用 A 生成摘要的内容也会比用 B 快 10 倍。哈希算法应该快速,但不能快到影响算法的安全性。
准备中
从 Cabal 安装 Murmur 哈希算法,如下所示:
$ cabal install murmur-hash
如何操作…
-
导入 Murmur 哈希算法,如下所示:
import Data.Digest.Murmur32
-
定义一个自定义数据类型并实现一个实例来使用 Murmur,如下所示:
data Point = Point Int Int instance (Hashable32 Point) where hash32Add (Point x y) h = x `hash32Add` (y `hash32Add` h)
-
在不同的输入上运行哈希算法,使用以下代码片段:
main = do let p1 = Point 0 0 let p2 = Point 2 3 putStrLn $ "hash of string: " ++ (show.hash32) "SO FAST WOW." putStrLn $ "hash of a data-type: " ++ (show.hash32) p1 putStrLn $ "hash of another data-type: " ++ (show.hash32) p2
-
生成以下哈希:
$ runhaskell Main.hs hash of string: Hash32 0xa18fa3d2 hash of a data-type: Hash32 0x30408e22 hash of another data-type: Hash32 0xfda11257
使用感知哈希衡量图像相似度
感知哈希从图像文件中生成一个小的摘要,其中图像的微小变化只会导致哈希值的轻微变化。这在快速比较成千上万张图像时非常有用。
准备中
从www.phash.org安装pHash
库。在基于 Debian 的系统上,我们可以使用apt-get
命令进行安装,如下所示:
$ sudo apt-get install libphash0-dev
从 Cabal 安装phash
库,如下所示:
$ cabal install phash
找到三张几乎相同的图像。我们将使用以下图像:
这是我们将使用的第二张图片
下面的图像是第三张:
如何操作…
-
导入
phash
库,代码如下:import Data.PHash import Data.Maybe (fromJust, isJust)
-
对一张图片进行哈希处理,结果如下:
main = do phash1 <- imageHash "image1.jpg" putStrLn $ "image1: " ++ show phash1
-
对一张相似的图片进行哈希处理,结果如下:
phash2 <- imageHash "image2.jpg" putStrLn $ "image2: " ++ show phash2
-
对一张稍微不同的图片进行哈希处理,结果如下:
phash3 <- imageHash "image3.jpg" putStrLn $ "image3: " ++ show phash3
-
使用以下代码片段计算前两张图片的相似度:
if isJust phash1 && isJust phash2 then do putStr "hamming distance between image1 and image2: " print $ hammingDistance (fromJust phash1) (fromJust phash2) else print "Error, could not read images"
-
计算第一张图片与第三张图片的相似度,结果如下:
if isJust phash1 && isJust phash3 then do putStr "hamming distance between image1 and image3: " print $ hammingDistance (fromJust phash1) (fromJust phash3) else print "Error, could not read images"
-
输出哈希值如下:
$ runhaskell Main.hs image1: Just (PHash 14057618708811251228) image2: Just (PHash 14488838648009883164) image3: Just (PHash 9589915937059962524) hamming distance between image1 and image2: 4 hamming distance between image1 and image3: 10
它是如何工作的…
在十六进制(或二进制)中可视化这些哈希值的相似性要容易得多,因为哈明距离是按比特操作的。
三张图片的十六进制表示如下:
-
图片 1: c316b1bc36947e1c
-
图片 2: c912b1fc36947e1c
-
图片 3: 851639bc3650fe9c
通过比较这些值,我们可以看到,图片 1 和图片 2 仅相差四个字符,而图片 1 和图片 3 相差整整 10 个字符。
第五章 树的舞蹈
本节涵盖了从创建简单的二叉树到哈夫曼编码等实际应用:
-
定义二叉树数据类型
-
定义玫瑰树(多路树)数据类型
-
深度优先遍历树
-
广度优先遍历树
-
为树实现可折叠实例
-
计算树的高度
-
实现二叉搜索树数据结构
-
验证二叉搜索树的顺序属性
-
使用自平衡树
-
实现最小堆数据结构
-
使用哈夫曼树对字符串进行编码
-
解码哈夫曼编码
简介
树是广泛应用于各种数据分析技术中的常见数据结构。树是一个节点的层次化连接,所有节点都在一个强大的根节点下。每个节点可以有零个或多个子节点,但每个子节点只与一个父节点相关联。此外,根节点是唯一没有父节点的特殊节点。所有没有子节点的节点也称为叶子节点。
在 Haskell 中,我们可以优雅地表示树,因为数据结构的递归性质利用了函数式编程的递归特性。本节将介绍创建我们自己的树以及使用库中现有的实现。
我们将实现堆和哈夫曼树,它们是数据分析中最著名的树结构之一。在本书的其他章节中,我们还会遇到 HTML/XML 遍历、层次聚类和决策树,这些都在很大程度上依赖于树数据结构。
定义二叉树数据类型
在二叉树中,每个节点最多有两个子节点。我们将定义一个数据结构来包含每个节点的左子树和右子树。
准备工作
本节中的代码将表示以下树结构。根节点标记为n3,值为3。它有一个左节点n1,值为1,和一个右节点n2,值为2。
如何做...
-
这段代码不需要任何导入。我们可以直接定义数据结构递归地。树可以是一个带有值的节点,也可以是空/null:
data Tree a = Node { value :: a , left :: (Tree a) , right:: (Tree a) } | Leaf deriving Show
-
在
main
中,创建如上图所示的树并将其打印出来:main = do let n1 = Node { value = 1, left = Leaf, right = Leaf } let n2 = Node { value = 2, left = Leaf, right = Leaf } let n3 = Node { value = 3, left = n1, right = n2 } print n3
-
完整的树结构打印结果如下:
$ runhaskell Main.hs Node { value = 3 , left = Node { value = 1 , left = Leaf , right = Leaf } , right = Node { value = 2 , left = Leaf , right = Leaf } }
另见
如果树中的节点需要超过两个子节点,则请参阅下一节,定义玫瑰树(多路树)数据类型。
定义玫瑰树(多路树)数据类型
玫瑰树放松了每个节点最多两个子节点的限制。它可以包含任意数量的元素。玫瑰树在解析 HTML 时很常见,用于表示文档对象模型(DOM)。
准备工作
我们将在本节中表示以下树结构。根节点有三个子节点:
如何做...
本节的配方不需要任何导入:
-
玫瑰树数据类型与二叉树的数据类型相似,不同之处在于它不会使用左、右子节点,而是存储一个任意数量的子节点的列表:
data Tree a = Node { value :: a , children :: [Tree a] } deriving Show
-
从前面的图示构建树并将其打印出来:
main = do let n1 = Node { value = 1, children = [] } let n2 = Node { value = 2, children = [] } let n3 = Node { value = 3, children = [] } let n4 = Node { value = 6, children = [n1, n2, n3] } print n4
-
打印的输出将如下所示:
$ runhaskell Main.hs Node { value = 6 , children = [ Node { value = 1 , children = [] } , Node { value = 2 , children = [] } , Node { value = 3 , children = [] } ] }
它是如何工作的……
玫瑰树不像使用专用的左、右字段来表示子节点,而是使用列表数据结构来表示任意数量的子节点。如果每个节点最多只能有两个子节点,则可以使用玫瑰树模拟二叉树。
另见
要表示一个二叉树,使用之前的方案可能会更简单,定义二叉树数据类型。
深度优先遍历树
本方案将展示一种遍历树的方法。该算法从根节点开始,沿着一条分支深入探索所有节点,直到回到较浅的节点继续探索。
由于我们会在递归地检查子节点之前检查每个节点,因此我们称之为先序遍历。相反,如果我们在检查每个节点之后再检查其子节点,那么我们称这种方法为后序遍历。介于两者之间的是中序遍历,但自然地,玫瑰树没有唯一的中序遍历。
使用深度优先方法的最大优势是最小的空间复杂度。视频游戏中的 AI 常常使用深度优先方法来确定对抗对手时的最佳动作。然而,在庞大或无限的树中,如果我们不断访问后续子节点,深度优先搜索可能永远无法终止。
准备就绪
我们将以深度优先的方式遍历以下树。我们从节点r开始,首先探索节点n1,然后是n2,接着返回查找n3,最后回溯到n4,并最终结束。
如何实现……
-
我们将使用
Data.Tree
中的现有玫瑰树实现:import Data.Tree (rootLabel, subForest, Tree(..)) import Data.List (tails)
-
这个函数将会深度优先遍历树:
depthFirst :: Tree a -> [a] depthFirst (Node r forest) = r : concat [depthFirst t | t <- forest]
-
这是一个深度优先实现,目的是将树中的所有值相加:
add :: Tree Int -> Int add (Node r forest) = r + sum [add t | t <- forest]
-
定义一个树来表示前面的图示:
someTree :: Tree Int someTree = r where r = Node { rootLabel = 0, subForest = [n1, n4] } n1 = Node { rootLabel = 1, subForest = [n2, n3] } n2 = Node { rootLabel = 2, subForest = [] } n3 = Node { rootLabel = 3, subForest = [] } n4 = Node { rootLabel = 4, subForest = [] }
-
测试深度优先函数:
main = do print $ depthFirst someTree print $ add someTree
-
这将打印出以下两行输出:
$ runhaskell Main.hs [0,1,2,3,4] 10
它是如何工作的……
在这个方案中,我们使用了来自Data.Tree
的内置玫瑰树数据结构。与我们在前一个方案中的实现类似,它的Tree
数据类型具有以下构造函数:
data Tree a = Node { rootLabel :: a
, subForest :: Forest a }
我们在每个子节点上递归运行depthFirst
算法,并将其附加到节点的值上,从而创建一个表示树遍历的列表。
另见
如果更倾向于按树的层次遍历树,请查看下一节,广度优先遍历树。
广度优先遍历树
在树的广度优先搜索方法中,节点按照树的深度顺序被访问。首先访问根节点,然后是它的子节点,再接着是每个子节点的子节点,依此类推。这个过程比深度优先遍历需要更大的空间复杂度,但在优化搜索算法时非常有用。
例如,假设你试图从维基百科文章中找到所有相关的主题。以广度优先的方式遍历文章中的所有链接,有助于确保主题从一开始就具有相关性。
准备就绪
请查看下图中的树。广度优先遍历将从根节点r开始,然后继续到下一级,遇到n1和n4,最后是n2和n3。
如何实现...
-
我们将使用来自
Data.Tree
的现有玫瑰树实现:import Data.Tree (rootLabel, subForest, Tree(..)) import Data.List (tails)
-
实现树的广度优先遍历:
breadthFirst :: Tree a -> [a] breadthFirst t = bf [t] where bf forest | null forest = [] | otherwise = map rootLabel forest ++ bf (concat (map subForest forest))
-
为了演示,实现一个函数来添加树中每个节点的值。
add :: Tree Int -> Int add t = sum $ breadthFirst t
-
根据前面的图表创建一棵树:
someTree :: Tree Int someTree = root where root = Node { rootLabel = 0, subForest = [n1, n4] } n1 = Node { rootLabel = 1, subForest = [n2, n3] } n2 = Node { rootLabel = 2, subForest = [] } n3 = Node { rootLabel = 3, subForest = [] } n4 = Node { rootLabel = 4, subForest = [] }
-
在
main
中测试广度优先算法:main = do print $ breadthFirst someTree print $ add someTree
-
输出结果如下:
$ runhaskell Main.hs [0,1,4,2,3] 10
它是如何工作的…
在这个配方中,我们使用了Data.Tree
中的内置玫瑰树数据结构。与我们之前某个配方中的实现类似,它具有以下构造器的Tree
数据类型:
data Tree a = Node { rootLabel :: a
, subForest :: Forest a }
我们通过创建一个列表来执行广度优先搜索,该列表从节点的直接子节点的值开始。然后,附加子节点的子节点的值,依此类推,直到树完全遍历。
另见
如果空间复杂度成为问题,那么之前的配方,深度优先遍历树,可能会提供更好的方法。
为树实现一个Foldable
实例
遍历树的概念可以通过实现一个Foldable
实例来推广。通常,fold 操作用于列表;例如,foldr1 (+) [1..10]
遍历一个数字列表以产生总和。类似地,我们可以应用foldr1 (+) tree
来找到树中所有节点的总和。
准备就绪
我们将通过以下树来折叠并获取所有节点值的总和。
如何实现...
-
导入以下内置包:
import Data.Monoid (mempty, mappend) import qualified Data.Foldable as F import Data.Foldable (Foldable, foldMap)
-
Data.Tree
中的树已经实现了Foldable
,因此我们将定义自己的树数据类型用于演示:data Tree a = Node { value :: a , children :: [Tree a] } deriving Show
-
为
Foldable
实例实现foldMap
函数。这个实现将给我们树的后序遍历:instance Foldable Tree where foldMap f Null = mempty foldMap f (Node val xs) = foldr mappend (f val) [foldMap f x | x <- xs]
-
定义一个函数通过树折叠以找到所有节点的和:
add :: Tree Integer -> Integer add = F.foldr1 (+)
-
构造一个表示前面图表中树的树:
someTree :: Tree Integer someTree = root where root = Node { value = 0, children = [n1, n4] } n1 = Node { value = 1, children = [n2, n3] } n2 = Node { value = 2, children = [] } n3 = Node { value = 3, children = [] } n4 = Node { value = 4, children = [] }
-
通过在树上运行
add
函数来测试折叠操作:main :: IO () main = print $ add someTree
-
结果将打印如下:
$ runhaskell Main.hs 10
它是如何工作的...
定义Foldable
实例所需的函数是foldMap
或foldr
。在本节中,我们定义了foldMap :: (Foldable t, Data.Monoid.Monoid m) => (a -> m) -> t a -> m
函数,该函数实际上将一个函数f
映射到树中的每个节点,并通过使用Data.Monoid
中的mappend
将其连接起来。
另请参见
遍历树的其他方法在前两节中讨论过,深度优先遍历树和广度优先遍历树。
计算树的高度
树的高度是从根节点到最深路径的长度。例如,一个平衡二叉树的高度应该大约是节点数量的以 2 为底的对数。
准备就绪
只要我们保持一致,树的高度可以定义为最长路径中节点的数量或边的数量。在本节中,我们将通过节点的数量来计算。该树的最长路径包含三个节点和两条边。因此,这棵树的高度为三个单位。
如何操作...
-
从
Data.List
导入maximum function
,并从Data.Tree
导入内置的树数据结构:import Data.List (maximum) import Data.Tree
-
定义一个函数来计算树的高度:
height :: Tree a -> Int height (Node val []) = 1 height (Node val xs) = 1 + maximum (map height xs)
-
构建一棵树,我们将在其上运行算法:
someTree :: Tree Integer someTree = root where root = 0 [n1, n4] n1 = 1 [n2, n3] n2 = 2 [] n3 = 3 [] n4 = 4 []
-
在
main
中测试该函数:main = print $ height someTree
-
树的高度将如下所示打印出来:
$ runhaskell Main.hs 3
它是如何工作的...
height
函数通过递归查找其子树中的最大高度,并返回该值加一。
实现二叉搜索树数据结构
二叉搜索树对二叉树施加了一种顺序属性。这个顺序属性要求在每个节点中,左子树中的节点不能大于当前节点,右子树中的节点不能小于当前节点。
如何操作...
-
创建一个二叉
BSTree
模块,用于暴露我们的二叉搜索树数据结构。将以下代码插入一个名为BSTree.hs
的文件中:module BSTree (insert, find, single) where
-
定义二叉树的数据结构:
data Tree a = Node {value :: a , left :: (Tree a) , right :: (Tree a)} | Null deriving (Eq, Show)
-
定义一个便捷函数来创建一个单节点树:
single :: a -> Tree a single n = Node n Null Null
-
实现一个函数,用于向二叉搜索树中插入新值:
insert :: Ord a => Tree a -> a -> Tree a insert (Node v l r) v' | v' < v = Node v (insert l v') r | v' > v = Node v l (insert r v') | otherwise = Node v l r insert _ v' = Node v' Null Null
-
实现一个函数,用于在二叉搜索树中查找具有特定值的节点:
find :: Ord a => Tree a -> a -> Bool find (Node v l r) v' | v' < v = find l v' | v' > v = find r v' | otherwise = True find Null v' = False
-
现在,测试
BSTree
模块,在一个新的文件中写入以下代码,可以命名为Main.hs
:import BSTree
-
在
main
中,通过对不同的值调用insert
函数构造一棵二叉搜索树:main = do let tree = single 5 let nodes = [6,4,8,2,9] let bst = foldl insert tree nodes
-
打印树并测试
find
函数:print bst print $ find bst 1 print $ find bst 2
-
输出应如下所示:
$ runhaskell Main.hs Node { value = 5 , left = Node { value = 4 , left = Node { value = 2 , left = Null , right = Null } , right = Null } , right = Node { value = 6 , left = Null , right = Node { value = 8 , left = Null , right = Node { value = 9 , left = Null , right = Null } } } } False True
它是如何工作的...
二叉搜索树数据结构的核心功能是insert
和find
,分别用于在二叉搜索树中插入和查找元素。查找节点是通过遍历树并利用其顺序性质来完成的。如果值低于预期,它会检查左节点;否则,如果值较大,它会检查右节点。最终,这个递归算法要么找到所需的节点,要么到达叶节点,从而找不到该节点。
二叉搜索树不保证树是平衡的,因此不能期望有快速的 O(log n)查找操作。二叉搜索树总有可能最终看起来像一个列表数据结构(例如,当我们按照[1,2,3,4,5]的顺序插入节点时,检查最终结构)。
另请参见
给定一棵二叉树,可以使用以下名为验证二叉搜索树的顺序性质的实例来验证顺序性质。如果要使用平衡的二叉树,请参考实例使用自平衡树。
验证二叉搜索树的顺序性质
给定一棵二叉树,本实例将介绍如何验证它是否满足顺序性质,即左子树中的所有元素都较小,而右子树中的所有元素都较大。
准备工作
我们将验证以下树是否为二叉搜索树:
如何实现...
本实例无需导入任何包。请按照以下步骤检查树是否为二叉搜索树:
-
定义一个二叉树的数据结构:
data Tree a = Node { value :: a , left :: (Tree a) , right :: (Tree a)} | Null deriving (Eq, Show)
-
根据前面的图示构建一棵树:
someTree :: Tree Int someTree = root where root = Node 0 n1 n4 n1 = Node 1 n2 n3 n2 = Node 2 Null Null n3 = Node 3 Null Null n4 = Node 4 Null Null
-
定义一个函数来验证树是否遵循二叉顺序性质:
valid :: Ord t => Tree t -> Bool valid (Node v l r) = leftValid && rightValid where leftValid = if notNull l then valid l && value l <= v else True rightValid = if notNull r then valid r && v <= value r else True notNull t = t /= Null
-
在
main
中测试该功能:main = print $ valid someTree
-
很明显,这棵树不遵循顺序性质,因此,输出结果如下:
$ runhaskell Main.hs False
工作原理...
valid
函数递归地检查左子树是否包含小于当前节点的元素,右子树是否包含大于当前节点的元素。
使用自平衡树
AVL 树是一种平衡的二叉搜索树。每个子树的高度差最多为一。在每次插入或删除时,树会通过一系列旋转操作调整节点,使其保持平衡。平衡的树确保了高度最小化,从而保证查找和插入操作在O(log n)时间内完成。在这个实例中,我们将直接使用 AVL 树包,但自平衡树也可以在Data.Set
和Data.Map
实现中找到。
准备工作
我们将使用AvlTree
包来使用Data.Tree.AVL
:
$ cabal install AvlTree
如何实现...
-
导入相关的 AVL 树包:
import Data.Tree.AVL import Data.COrdering
-
从一个值列表中设置一个 AVL 树,并读取其中的最小值和最大值:
main = do let avl = asTree fstCC [4,2,1,5,3,6] let min = tryReadL avl let max = tryReadR avl print min print max
-
最小值和最大值如下所示:
$ runhaskell Main.hs Just 1 Just 6
工作原理...
asTree :: (e -> e -> COrdering e) -> [e] -> AVL
函数接受一个排序属性和一个元素列表,生成相应元素的 AVL 树。fstCC :: Ord a => a -> a -> COrdering a
函数来自 Data.Cordering
,其定义如下:
一个为 'Ord' 实例提供的组合比较,它在认为两个参数相等时保留第一个参数,第二个参数则被丢弃。
还有更多…
Haskell 的 Data.Set
和 Data.Map
函数实现高效地使用了平衡二叉树。我们可以通过简单地使用 Data.Set
来重写这个方法:
import qualified Data.Set as S
main = do
let s = S.fromList [4,2,1,5,3,6]
let min = S.findMin s
let max = S.findMax s
print min
print max
实现一个最小堆数据结构
堆是一个具有形状属性和堆属性的二叉树。形状属性通过定义每个节点除非位于最后一层,否则都必须有两个子节点,强制树以平衡的方式表现。堆属性确保如果是最小堆,则每个节点小于或等于其任何子节点,最大堆则相反。
堆用于常数时间查找最大或最小元素。我们将在下一个示例中使用堆来实现我们自己的霍夫曼树。
入门
安装 lens 库以便于数据操作:
$ cabal install lens
如何实现...
-
在
MinHeap.hs
文件中定义MinHeap
模块:module MinHeap (empty, insert, deleteMin, weights) where import Control.Lens (element, set) import Data.Maybe (isJust, fromJust)
-
我们将使用一个列表来表示二叉树数据结构,仅用于演示目的。最好将堆实现为实际的二叉树(如我们在前面的章节中所做的),或者我们应使用实际的数组来提供常数时间访问其元素。为了简化起见,我们将定义根节点从索引 1 开始。给定一个位于索引
i
的节点,左子节点将始终位于 2i,右子节点位于 2i + 1:data Heap v = Heap { items :: [Node v] } deriving Show data Node v = Node { value :: v, weight :: Int } deriving Show
-
我们定义一个方便的函数来初始化一个空堆:
empty = Heap []
-
在堆中插入一个节点的方法是将节点添加到数组的末尾,然后将其上浮:
insert v w (Heap xs) = percolateUp position items' where items' = xs ++ [Node v w] position = length items' - 1
-
从堆中删除一个节点的方法是将根节点与最后一个元素交换,然后从根节点开始向下浮动:
deleteMin (Heap xs) = percolateDown 1 items' where items' = set (element 1) (last xs) (init xs)
-
创建一个函数来查看最小值:
viewMin heap@(Heap (_:y:_)) = Just (value y, weight y, deleteMin heap) viewMin _ = Nothing
-
从一个节点向下浮动意味着确保当前节点的堆属性成立;否则,将节点与其较大或较小的子节点(根据是最大堆还是最小堆)交换。这个过程会递归地应用直到叶节点:
percolateDown i items | isJust left && isJust right = percolateDown i' (swap i i' items) | isJust left = percolateDown l (swap i l items) | otherwise = Heap items
-
定义
left
、right
、i'
、l
和r
变量:where left = if l >= length items then Nothing else Just $ items !! l right = if r >= length items then Nothing else Just $ items !! r i' = if (weight (fromJust left)) < (weight (fromJust right)) then l else r l = 2*i r = 2*i + 1
-
上浮一个节点意味着递归地将节点与其父节点交换,直到树的堆属性成立:
percolateUp i items | i == 1 = Heap items | w < w' = percolateUp c (swap i c items) | otherwise = Heap items where w = weight $ items !! i w' = weight $ items !! c c = i `div` 2
-
我们定义一个方便的函数来交换列表中两个索引处的元素:
swap i j xs = set (element j) vi (set (element i) vj xs) where vi = xs !! i vj = xs !! j
-
为了查看堆在数组表示中的每个节点的权重,我们可以定义如下函数:
weights heap = map weight ((tail.items) heap)
-
最后,在一个我们可以命名为
Main.hs
的不同文件中,我们可以测试最小堆:import MinHeap main = do let heap = foldr (\x -> insert x x) empty [11, 5, 3, 4, 8] print $ weights heap print $ weights $ iterate deleteMin heap !! 1 print $ weights $ iterate deleteMin heap !! 2 print $ weights $ iterate deleteMin heap !! 3 print $ weights $ iterate deleteMin heap !! 4
-
堆的数组表示中的权重输出如下:
$ runhaskell Main.hs [3,5,4,8,11] [4,5,11,8] [5,8,11] [8,11] [11]
还有更多…
本节代码用于理解堆数据结构,但效率并不高。Hackage 上有更好的堆实现,包括我们将要探索的Data.Heap
库:
-
导入堆库:
import Data.Heap (MinHeap, MaxHeap, empty, insert, view)
-
定义一个辅助函数,从列表构建最小堆:
minheapFromList :: [Int] -> MinHeap Int minheapFromList ls = foldr insert empty ls
-
定义一个辅助函数,从列表构造最大堆:
maxheapFromList :: [Int] -> MaxHeap Int maxheapFromList ls = foldr insert empty ls
-
测试堆:
main = do let myList = [11, 5, 3, 4, 8] let minHeap = minheapFromList myList let maxHeap = maxheapFromList myList print $ view minHeap print $ view maxHeap
-
视图函数返回一个
Maybe
数据结构中的元组。元组的第一个元素是执行查找操作后的值,第二个元素是删除该值后的新堆:$ runhaskell Main.hs Just (3, fromList [(4,()),(11,()),(5,()),(8,())]) Just (11, fromList [(8,()),(3,()),(5,()),(4,())])
使用哈夫曼树对字符串进行编码
哈夫曼树通过计算字符的概率分布来优化每个字符所占的空间,从而实现高效的数据编码。想象一下将这本书压缩成一张纸,再恢复回来而不丢失任何信息。哈夫曼树允许这种基于统计数据的最优无损数据压缩。
在本节中,我们将实现一个哈夫曼树,从文本源生成它的哈夫曼编码的字符串表示。
例如,字符串“hello world”包含 11 个字符,这些字符根据编码方式和架构的不同,可能只需要占用 11 个字节的空间来表示。本节代码将把该字符串转换成 51 位,或 6.375 字节。
准备工作
请确保连接到互联网,因为本节会从norgiv.com/big.txt
下载文本以分析许多字符的概率分布。我们将使用前一个食谱中实现的最小堆,通过导入MinHeap
:
如何操作...
-
导入以下包。我们将使用之前的
MinHeap
模块,因此请确保包含之前食谱中的代码:import Data.List (group, sort) import MinHeap import Network.HTTP ( getRequest, getResponseBody, simpleHTTP ) import Data.Char (isAscii) import Data.Maybe (fromJust) import Data.Map (fromList, (!))
-
定义一个函数,返回字符与其频率的关联列表:
freq xs = map (\x -> (head x, length x)) . group . sort $ xs
-
哈夫曼树的数据结构就是一个二叉树:
data HTree = HTree { value :: Char , left :: HTree , right :: HTree } | Null deriving (Eq, Show)
-
使用一个值构造哈夫曼树:
single v = HTree v Null Null
-
定义一个函数,通过最小堆构建哈夫曼树:
htree heap = if length (items heap) == 2 then case fromJust (viewMin heap) of (a,b,c) -> a else htree $ insert newNode (w1 + w2) heap3 where (min1, w1, heap2) = fromJust $ viewMin heap (min2, w2, heap3) = fromJust $ viewMin heap2 newNode = HTree { value = ' ' , left = min1 , right = min2 }
-
从哈夫曼树获取哈夫曼编码的映射:
codes htree = codes' htree "" where codes' (HTree v l r) str | l==Null && r==Null = [(v, str)] | r==Null = leftCodes | l==Null = rightCodes | otherwise = leftCodes ++ rightCodes where leftCodes = codes' l ('0':str) rightCodes = codes' r ('1':str)
-
定义一个函数,使用哈夫曼编码将字符串编码为文本:
encode str m = concat $ map (m !) str
-
通过在
main
中执行以下操作来测试整个过程。下载并计算频率可能需要几分钟:main = do rsp <- simpleHTTP (getRequest "http://norvig.com/big.txt") html <- fmap (takeWhile isAscii) (getResponseBody rsp) let freqs = freq html let heap = foldr (\(v,w) -> insert (single v) w) empty freqs let m = fromList $ codes $ htree heap print $ encode "hello world" m
-
哈夫曼树的字符串表示形式如下所示:
$ runhaskell Main.hs "010001110011110111110001011101000100011011011110010"
工作原理...
首先,我们通过从norvig.com/big.txt
下载文本来获取分析数据源。接下来,我们获取每个字符的频率映射并将其放入堆中。哈夫曼树通过将两个最低频率的节点合并,直到最小堆中只剩下一个节点。最后,使用哈夫曼编码对示例字符串“hello world”进行编码。
另见
要读取编码后的哈夫曼值,请参阅下一节,解码哈夫曼编码。
解码哈夫曼编码
这段代码在很大程度上依赖于前面的食谱,使用哈夫曼树编码字符串。接下来使用相同的哈夫曼树数据结构来解码哈夫曼编码的字符串表示。
准备工作
阅读前面的食谱,使用哈夫曼树编码字符串。本食谱中使用的是相同的HTree
数据结构。
如何操作...
我们沿着树进行遍历,直到遇到叶子节点。然后,前置找到的字符并从根节点重新开始。这个过程会持续进行,直到没有输入可用为止:
decode :: String -> HTree -> String
decode str htree = decode' str htree
where decode' "" _ = ""
decode' ('0':str) (HTree _ l _)
| leaf l = value l : decode' str htree
| otherwise = decode' str l
decode' ('1':str) (HTree v _ r)
| leaf r = value r : decode' str htree
| otherwise = decode' str r
leaf tree = left tree == Null && right tree == Null
另请参见
要使用哈夫曼树编码数据,请参见前面的食谱,使用哈夫曼树编码字符串。
第六章 图基础
本章我们将涵盖以下方法:
-
从边的列表表示图
-
从邻接表表示图
-
对图进行拓扑排序
-
深度优先遍历图
-
图的广度优先遍历
-
使用 Graphviz 可视化图
-
使用有向无环图
-
处理六边形和方形网格网络
-
查找图中的最大团
-
确定两个图是否同构
介绍
本节关于图的内容是对前一节关于树的内容的自然扩展。图是表示网络的基本数据结构,本章将涵盖一些重要的算法。
图减轻了树的一些限制,使得可以表示网络数据,如生物基因关系、社交网络和道路拓扑。Haskell 支持多种图数据结构库,提供了各种有用的工具和算法。本节将涵盖图的表示、拓扑排序、遍历以及与图相关的包等基础话题。
从边的列表表示图
图可以通过一组边来定义,其中边是顶点的元组。在Data.Graph
包中,顶点就是Int
。在这个方法中,我们使用buildG
函数根据一组边构建图数据结构。
准备就绪
我们将构建如下图所示的图:
如何做……
创建一个新文件,我们将其命名为Main.hs
,并插入以下代码:
-
导入
Data.Graph
包:import Data.Graph
-
使用导入库中的
buildG
函数构建图:myGraph :: Graph myGraph= buildG bounds edges where bounds = (1,4) edges = [ (1,3), (1,4) , (2,3), (2,4) , (3,4) ]
-
打印图、它的边和顶点:
main = do print $ "The edges are " ++ (show.edges) myGraph print $ "The vertices are " ++ (show.vertices) myGraph
它是如何工作的……
一组边被传递给buildG :: Bounds -> [Edge] -> Graph
函数以构建图数据结构。第一个参数指定了顶点的上下边界,第二个参数指定了组成图的边的列表。
这种图数据类型实际上是一个 Haskell 数组,表示从顶点到顶点列表的映射。它使用了内建的Data.Array
包,意味着我们可以在图中使用Data.Array
提供的所有函数。
另见
要了解另一种构建图的方式,请参见下一个方法,从邻接表表示图。
从邻接表表示图
给定邻接表构建图可能会更方便。在本方法中,我们将使用内建的Data.Graph
包来读取顶点与其连接的顶点列表之间的映射。
准备就绪
我们将构建如下图所示的图:
如何做……
创建一个新文件,我们将其命名为Main.hs
,并插入以下代码:
-
导入
Data.Graph
包:import Data.Graph
-
使用
graphFromEdges'
函数获取包含图形的元组。元组的第一个元素是图数据结构Graph
,第二个元素是从节点编号到其相应值的映射Vertex -> (node, key, [key])
:myGraph :: Graph myGraph = fst $ graphFromEdges' [ ("Node 1", 1, [3, 4] ) , ("Node 2", 2, [3, 4]) , ("Node 3", 3, [4]) , ("Node 4", 4, []) ]
-
输出一些图形计算结果:
main = do putStrLn $ "The edges are "++ (show.edges) myGraph putStrLn $ "The vertices are "++ (show.vertices) myGraph
-
运行代码会显示图形的边和节点:
$ runhaskell Main.hs The edges are [(0,2), (0,3), (1,2), (1,3), (2,3)] The vertices are [0, 1, 2, 3]
它是如何工作的...
我们可能会注意到,每个节点的键已经由算法自动分配。graphFromEdges'
函数实际上返回一个类型为 (Graph, Vertex -> (node, key, [key]))
的元组,其中第一个元素是图数据结构,第二个元素是从节点编号到其实际键的映射。
与之前的配方一样,这个图数据结构实际上是来自 Data.Array
包的一个数组,这意味着我们可以在图形中使用 Data.Array
提供的所有函数。
参见:
如果我们希望从边的列表创建图形,之前的配方 从邻接表表示图形 可以完成这个任务。
对图进行拓扑排序
如果图是有向图,那么拓扑排序是图的自然排序之一。在依赖关系网络中,拓扑排序将揭示满足这些依赖关系的所有顶点的可能排列。
Haskell 内置的图形包提供了一个非常有用的函数 topSort
,可以对图形进行拓扑排序。在这个配方中,我们将创建一个依赖关系图,并对其进行拓扑排序。
准备工作
我们将从用户输入中读取数据。每一对行将表示一个依赖关系。
创建一个名为 input.txt
的文件,文件内容是以下依赖项对:
$ cat input.txt
understand Haskell
do Haskell data analysis
understand data analysis
do Haskell data analysis
do Haskell data analysis
find patterns in big data
该文件描述了一个依赖关系列表,内容如下:
-
必须理解 Haskell 才能进行 Haskell 数据分析
-
必须理解数据分析才能进行 Haskell 数据分析
-
必须进行 Haskell 数据分析才能在大数据中找到模式
提示
我们将使用 Data.Graph
提供的 topsort
算法。请注意,这个函数不能检测循环依赖。
如何实现...
在一个新文件中,我们将其命名为 Main.hs
,插入以下代码:
-
从图形、映射和列表包中导入以下内容:
import Data.Graph import Data.Map (Map, (!), fromList) import Data.List (nub)
-
从输入中读取并根据依赖关系构建图形。对图执行拓扑排序并输出有效的顺序:
main = do ls <- fmap lines getContents let g = graph ls putStrLn $ showTopoSort ls g
-
从字符串列表构建图形,其中每一对行表示一个依赖关系:
graph :: Ord k => [k] -> Graph graph ls = buildG bounds edges where bounds = (1, (length.nub) ls) edges = tuples $ map (mappingStrToNum !) ls mappingStrToNum = fromList $ zip (nub ls) [1..] tuples (a:b:cs) = (a, b) : tuples cs tuples _ = []
-
对图进行拓扑排序,并输出有效的排序顺序:
showTopoSort :: [String] -> Graph -> String showTopoSort ls g = unlines $ map (mappingNumToStr !) (topSort g) where mappingNumToStr = fromList $ zip [1..] (nub ls)
-
编译代码并将依赖项的文本文件作为输入:
$ runhaskell Main.hs < input.txt understand data analysis understand Haskell do Haskell data analysis find patterns in big data
深度优先遍历图形
使用深度优先搜索,能够遍历图形以查看节点的期望顺序。实现拓扑排序、解决迷宫问题以及寻找连通分量,都是依赖于图的深度优先遍历的有用算法示例。
如何实现...
开始编辑一个新的源文件,我们将其命名为Main.hs
:
-
导入所需的包:
import Data.Graph import Data.Array ((!))
-
从邻接表构建图形:
graph :: (Graph, Vertex -> (Int, Int, [Int])) graph = graphFromEdges' [ (1, 1, [3, 4] ) , (2, 2, [3, 4]) , (3, 3, [4]) , (4, 4, []) ]
-
扫描图形进行深度优先遍历:
depth g i = depth' g [] i depth' g2(gShape, gMapping) seen i = key : concat (map goDeeper adjacent) where goDeeper v = if v `elem` seen then [] else depth' g (i:seen) v adjacent = gShape ! i (_, key, _) = gMapping i
-
打印出访问的顶点列表:
main = print $ depth graph 0
-
运行算法以查看遍历顺序。
$ runhaskell Main.hs [1, 3, 4, 4]
我们从节点 1(索引为 0)开始。我们沿着第一条边遍历到节点 3。从节点 3,我们沿着第一条边遍历到节点 4。由于 4 没有出边,我们回到节点 3。由于 3 没有剩余的出边,我们回到节点 1。从节点 1,我们沿着第二条边遍历到节点 4。
进行广度优先遍历图形
使用广度优先搜索,可以遍历图形以查看节点的顺序。在一个无限图中,深度优先遍历可能永远无法返回到起始节点。广度优先遍历算法的一个显著例子是找到两个节点之间的最短路径。
在本教程中,我们将打印出图中节点的广度优先遍历。
如何操作...
在新文件中插入以下代码,可以命名为Main.hs
:
-
导入所需的包:
import Data.Graph import Data.Array ((!))
-
从边的列表构建图形:
graph :: Graph graph = buildG bounds edges where bounds = (1,7) edges = [ (1,2), (1,5) , (2,3), (2,4) , (5,6), (5,7) , (3,1) ]
-
扫描图形进行广度优先遍历:
breadth g i = bf [] [i] where bf :: [Int] -> [Int] -> [Int] bf seen forest | null forest = [] | otherwise = forest ++ bf (forest ++ seen) (concat (map goDeeper forest)) where goDeeper v = if elem v seen then [] else (g ! v)
-
打印出深度优先遍历的访问顶点列表:
main = do print $ breadth graph 1
-
运行代码显示遍历结果:
$ runhaskell Main.hs [1, 5, 2, 7, 6, 4, 3, 1]
使用 Graphviz 可视化图形
使用graphviz
库,可以轻松绘制表示图形的图像。在数据分析的世界中,直观地解读图像可以揭示数据中的一些特征,这些特征是人眼容易捕捉到的。本教程将帮助我们根据所处理的数据构建一个图表。更多的可视化技术在第十一章,数据可视化中进行了详细说明。
准备工作
从www.graphviz.org/Download.php
安装graphviz
库,因为 Haskell 包需要它。
接下来,通过运行以下命令从 cabal 安装该软件包:
$ cabal install graphviz
如何操作...
在新文件中插入以下代码。我们将文件命名为Main.hs
:
-
导入包:
import Data.GraphViz
-
从节点和边创建图形:
graph :: DotGraph Int graph = graphElemsToDot graphParams nodes edges
-
使用默认参数创建图形。此函数可以修改以调整图形的可视化参数:
graphParams :: GraphvizParams Int String Bool () String graphParams = defaultParams
-
根据相应的边创建代码:
nodes :: [(Int, String)] nodes = map (\x -> (x, "")) [1..4] edges:: [(Int, Int, Bool)] edges= [ (1, 3, True) , (1, 4, True) , (2, 3, True) , (2, 4, True) , (3, 4, True)]
-
执行
main
以输出图形:main = addExtension (runGraphviz graph) Png "graph"
使用有向无环词图
我们使用有向无环词图(DAWG)从大量字符串语料库中快速检索,且在空间复杂度上几乎不占空间。想象一下,使用 DAWG 压缩词典中的所有单词,从而实现高效的单词查找。这是一种强大的数据结构,当处理大量单词时非常有用。关于 DAWG 的一个非常好的介绍可以在 Steve Hanov 的博客文章中找到:stevehanov.ca/blog/index.php?id=115
。
我们可以使用此方法将 DAWG 集成到我们的代码中。
准备工作
使用 cabal 安装 DAWG 包:
$ cabal install dawg
如何做...
我们命名一个新文件Main.hs
并插入以下代码:
-
导入以下包:
import qualified Data.DAWG.Static as D import Network.HTTP ( simpleHTTP, getRequest, getResponseBody) import Data.Char (toLower, isAlphaNum, isSpace) import Data.Maybe (isJust)
-
在
main
函数中,下载大量文本以存储:main = do let url = "http://norvig.com/big.txt" body <- simpleHTTP (getRequest url) >>= getResponseBody
-
从由语料库构建的 DAWG 中查找一些字符串:
let corp = corpus body print $ isJust $ D.lookup "hello" corp print $ isJust $ D.lookup "goodbye" corp
-
构建一个获取函数:
getWords :: String -> [String] getWords str = words $ map toLower wordlike where wordlike = filter (\x -> isAlphaNum x || isSpace x) str
-
从语料库字典创建一个 DAWG:
corpus :: String -> D.DAWG Char () () corpus str = D.fromLang $ getWords str
-
运行代码显示,这两个词确实存在于大规模语料库中。请注意,需要一个耗时的预处理步骤来构建 DAWG:
$ runhaskell Main.hs True True
提示
一个天真的方法可能是使用Data.List
中的isInfixOf
函数执行子字符串搜索。在一台配备 8 GB RAM 的 Intel i5 处理器的 Typical ThinkPad T530 上,执行isInfixOf
操作的平均时间约为 0.16 秒。然而,如果我们预处理 DAWG 数据结构,则查找时间小于 0.01 秒!
处理六边形和方形网格网络
有时,我们处理的图具有严格的结构,例如六边形或方形网格。许多视频游戏使用六边形网格布局来促进对角线移动,因为在方形网格中对角线移动会使得移动距离的值变得复杂。另一方面,方形网格结构经常用于图像处理算法(如洪泛填充)中的像素遍历。
Haskell 包列表中有一个非常有用的库用于处理这样的拓扑结构。我们可以获取网格的索引以遍历世界,这实质上是嵌入图中的路径。对于每个网格索引,我们可以查询库以找到相邻的索引,有效地使用网格作为图形。
入门指南
查阅位于github.com/mhwombat/grid/wiki
的包文档:
使用 cabal 安装 grid 包:
$ cabal install grid
如何做...
在一个新文件中,我们将命名为Main.hs
,插入以下代码:
-
导入以下库:
import Math.Geometry.Grid (indices, neighbours) import Math.Geometry.Grid.Hexagonal (hexHexGrid) import Math.Geometry.Grid.Square (rectSquareGrid) import Math.Geometry.GridMap ((!)) import Math.Geometry.GridMap.Lazy (lazyGridMap)
-
在
main
函数中,打印一些六边形和网格函数的示例:main = do let putStrLn' str = putStrLn ('\n':str) putStrLn' "Indices of hex grid:" print $ indices hex putStrLn' "Neighbors around (1,1) of hex grid:" print $ neighbours hex (1,1) putStrLn' "Indices of rect grid:" print $ indices rect putStrLn' "Neighbors around (1,1) of rect grid:" print $ neighbours rect (1,1) putStrLn' "value of hex at index (1,1)" print $ hexM ! (1,1)
-
使用一个辅助函数来构建六边形网格:
hex = hexHexGrid 4
-
使用一个辅助函数来构建方形网格:
rect = rectSquareGrid 3 5
-
创建一个带有相关数值的六边形网格:
hexM = lazyGridMap hex [1..]
在图中查找最大的团
Haskell 带有许多重要的图形库,其中一个便利的图形库是来自Data.Algorithm.MaximalCliques
的团检测库。图中的团是一个子图,其中所有节点之间都有连接,如下所示:
例如,上述图中包含两个不同颜色阴影的团。也许,该图表示相互链接的网页。从图的结构可以直观推断,由于网络连接的结构,可能存在两个互联网社区的集群。随着连接的网络增加,查找最大团变得成倍困难。
在这个食谱中,我们将使用最大团问题的高效实现。
入门
使用 cabal 安装 clique 库:
$ cabal install maximal-cliques
如何操作...
在一个新文件中编写以下代码,我们将其命名为Main.hs
:
-
导入所需的库:
import Data.Algorithm.MaximalCliques
-
在
main
中,打印出最大团:main = print $ getMaximalCliques edges nodes
-
创建以下图形:
edges 1 5 = True edges 1 2 = True edges 2 3 = True edges 2 5 = True edges 4 5 = True edges 3 4 = True edges 4 6 = True edges _ _ = False
-
确定节点范围:
nodes = [1..6]
它是如何工作的...
该库应用递归的 Bron-Kerbosch 枢轴算法来识别无向图中的最大团。算法的核心思想是智能回溯,直到找到最大团为止。
确定两个图是否同构
图形可以拥有任意标签,但它们的拓扑可能是同构的。在数据分析的世界里,我们可以检查不同的图形网络,并识别出连接模式相同的节点群体。这帮助我们发现当两个看似不同的图形网络最终拥有相同的网络映射时。也许这时我们可以声明节点之间的一一同构关系,并从中学习一些关于图形本质的深刻知识。
我们将使用Data.Graph.Automorphism
中的isIsomorphic
函数来检测两个图是否在其连接上相同。
在这个食谱中,我们将让库计算下图中两个图是否在其连接上是同构的:
入门
安装 Automorphism 库:
$ cabal install hgal
如何操作...
在一个新文件中编写以下代码,我们将其命名为Main.hs
:
-
导入以下包:
import Data.Graph import Data.Graph.Automorphism
-
构建一个图:
graph = buildG (0,4) [ (1, 3), (1, 4) , (1, 2), (2, 3) , (2, 4), (3, 4) ]
-
构建另一个图:
graph' = buildG (0,4) [ (3, 1), (3, 2) , (3, 4), (4, 1) , (4, 2), (1, 2) ]
-
检查图形是否具有相同的拓扑:
main = print $ isIsomorphic graph graph'
第七章 统计与分析
分析大数据的一个核心动机是寻找内在的模式。本章包含了可以回答关于数据偏离常态、线性与二次趋势的存在,以及网络的概率值等问题的配方。通过以下配方,可以揭示一些最具魅力的结果:
-
计算移动平均
-
计算移动中位数
-
逼近线性回归
-
逼近二次回归
-
从样本中获得协方差矩阵
-
查找列表中的所有唯一配对
-
使用皮尔逊相关系数
-
评估贝叶斯网络
-
创建一个扑克牌的数据结构
-
使用马尔科夫链生成文本
-
从列表中创建 n-gram
-
构建神经网络感知
介绍
前两个配方处理的是总结一系列数据。例如,假设有人问:“每个人的年龄是多少?”一个有效的回答可能是列举每个人的年龄,但根据人数的不同,如果人数多,这可能需要几分钟甚至几小时。相反,我们可以通过平均值或中位数来回答,将所有年龄值总结为一个简单的数字。
接下来的两个配方是关于逼近最能拟合一组点的方程。给定两组坐标,我们可以使用线性或二次逼近来预测其他点。
我们可以通过协方差矩阵和皮尔逊相关性计算来检测数值数据之间的关系,正如在相应的配方中展示的那样。
Numeric.Probability.Distribution
库包含许多有用的函数,能够帮助深入理解统计学,正如在贝叶斯网络和扑克牌配方中所展示的那样。
我们还将使用马尔科夫链和 n-gram 以获得更多有趣的结果。
最后,我们将从零开始创建一个神经网络,来学习一个带标签的数据集。
计算移动平均
将一系列数字总结成一个代表性数字可以通过计算平均值来完成。算术平均数的公式是将所有数值相加,再除以数值的个数。然而,如果所加的数值极其庞大,最终的总和可能会溢出。
在 Haskell 中,Int
的范围至少是从 -2²⁹ 到 2²⁹-1。实现可以允许具有更大范围的 Int
类型。如果我们尝试通过先计算它们的总和来天真地计算 2²⁹-2 和 2²⁹-3 的平均值,总和可能会溢出,从而导致平均值的计算错误。
移动平均(或滚动平均)试图避免这一缺陷。我们将使用指数平滑策略,这意味着之前看到的数字对当前移动均值的贡献将以指数方式递减。指数移动平均对最近的数据反应更快。它可以用于检测价格波动或在神经网络中激活神经元。
移动平均的公式如下,其中α是平滑常数,s[t]是时刻t的移动平均值,x是原始数据的值:
准备工作
创建一个input.txt
文件,里面是由换行符分隔的数字列表。我们将对这些数字计算移动平均值:
$ cat input.txt
4
3
2
5
3
4
1
3
12
3
如何实现……
-
创建一个辅助函数将原始文本输入转换为
Double
类型的列表,如下所示:clean raw = map (\s -> read s :: Double) (lines raw)
-
使用指数平滑技术计算数字列表的移动平均值。我们将平滑常数
a
硬编码为0.95
,如这里所示:avg :: [Double] -> Double avg (x:xs) = a*x + (1-a)*(avg xs) where a = 0.95 avg [] = 0
-
计算真实的算术平均值,以比较差异:
mean xs = (sum xs) / (fromIntegral (length xs))
-
输出计算移动平均和算术平均的结果,看看它们的值虽然不相等,但非常接近:
main = do rawInput <- readFile "input.txt" let input = clean rawInput print input putStrLn $ "mean is " ++ (show.mean) input putStrLn $ "moving average is " ++ (show.avg) input
-
我们将看到以下输出:
$ runhaskell Main.hs [4.0,3.0,2.0,5.0,3.0,4.0,1.0,3.0,12.0,3.0] mean is 4.0 moving average is 3.9478627675211913
还有更多……
平滑常数应该根据数据的波动来调整。较小的平滑常数能更好地记住先前的值,并产生受数据总体结构影响的平均值。另一方面,较大的平滑常数则更加关注最新的数据,并容易忘记过时的数据。如果我们希望平均值更敏感于新数据,就应使用较大的平滑常数。
另见
总结数字列表的另一种方式可以参考计算移动中位数的做法。
计算移动中位数
数字列表的中位数有相等数量的值小于和大于它。计算中位数的朴素方法是简单地对列表排序并选取中间的数字。然而,在一个非常大的数据集上,这样的计算会效率低下。
另一种寻找移动中位数的方法是使用最小堆和最大堆的组合,在遍历数据时对值进行排序。我们可以将数字插入到任何一个堆中,并且在需要时,可以通过调整堆的大小使其保持相等或接近相等,从而计算中位数。当堆的大小相等时,找到中间的数字就很简单,这就是中位数。
准备工作
创建一个包含一些数字的文件input.txt
:
$ cat input.txt
3
4
2
5
6
4
2
6
4
1
使用 Cabal 安装一个处理堆的库,方法如下:
$ cabal install heap
如何实现……
-
导入堆数据结构:
import Data.Heap import Data.Maybe (fromJust)
-
将原始输入转换为如下所示的数字列表:
clean raw = map (\s -> read s :: Int) (lines raw)
-
将列表中的数字划分到适当的堆中。将小的数字放入最大堆,将大的数字放入最小堆,代码示例如下:
median (x:xs) maxheap minheap = case viewHead maxheap of Just theMax -> if x < theMax then median xs (insert x maxheap) minheap else median xs maxheap (insert x minheap) Nothing -> median xs (insert x maxheap) minheap
-
当没有更多数字可以读取时,开始操作堆直到两者大小相等。中位数将是两个堆中数值之间的数字,代码示例如下:
median [] maxheap minheap | size maxheap + 1 < size minheap = median [] (insert minelem maxheap) $ (snd.fromJust.view) minheap | size minheap + 1 < size maxheap = median [] ((snd.fromJust.view) maxheap) $ insert maxelem minheap | size maxheap == size minheap = (fromIntegral maxelem + fromIntegral minelem)/2.0 | size maxheap > size minheap = fromIntegral maxelem | otherwise = fromIntegral minelem where maxelem = fromJust (viewHead maxheap) minelem = fromJust (viewHead minheap)
-
测试
main
中的代码,如下所示:main = do rawInput <- readFile "input.txt" let input = clean rawInput print $ input print $ median input (empty :: MaxHeap Int) (empty :: MinHeap Int)
-
输出如下:
$ runhaskell Main.hs [3,4,2,5,6,4,2,6,4,1] 4.0
它是如何工作的……
首先,我们遍历数字列表,构建一个最小堆和最大堆,以有效地分离流入的数字。然后,我们在最小堆和最大堆之间移动值,直到它们的大小差异不超过一个项目。中位数是额外的项目,或者是最小堆和最大堆值的平均值。
另见
若要以不同方式总结一组数字,请参考计算移动平均的配方。
近似线性回归
给定一组点,我们可以使用一个方便的库Statistics.LinearRegression
来估算最佳拟合线。
它计算点之间的最小二乘差异来估算最佳拟合线。以下图显示了点的线性回归示例:
使用线性回归通过五个点绘制最佳拟合线
准备工作
使用 cabal 安装相应的库,如下所示:
$ cabal install statistics-linreg
如何实现……
-
导入以下包:
import Statistics.LinearRegression import qualified Data.Vector.Unboxed as U
-
从它们的坐标创建一系列点,并将其传递给
linearRegression
函数,如以下代码片段所示:main = do let xs = U.fromList [1.0, 2.0, 3.0, 4.0, 5.0] :: U.Vector Double let ys = U.fromList [1.0, 2.0, 1.3, 3.75, 2.25]::U.Vector Double let (b, m) = linearRegression xs ys print $ concat ["y = ", show m, " x + ", show b]
-
结果线性方程如下:
$ runhaskell Main.hs "y = 0.425 x + 0.785"
它是如何工作的……
我们可以查找Statistics.LinearRegression
库中的linearRegression
函数的源代码,网址为:hackage.haskell.org/package/statistics-linreg-0.2.4/docs/Statistics-LinearRegression.html
。
维基百科关于最小二乘近似的文章(en.wikipedia.org/wiki/Linear_least_squares_(mathematics)
)写得最好:
“解决这个问题的最小二乘法是尽可能使右侧和左侧方程之间的“误差”平方和最小,也就是找到该函数的最小值”
核心计算涉及寻找两个随机变量的均值和方差,以及它们之间的协方差。算法背后的详细数学原理可以在www.dspcsp.com/pubs/euclreg.pdf
中找到。
如果我们查看库的源代码,我们可以发现其中的基本方程:
α = µY - β * µX
β = covar(X,Y)/σ²X
f(x) = βx + α
另见
如果数据不符合线性趋势,请尝试近似二次回归的配方。
近似二次回归
给定一组点,本配方将尝试找到最佳拟合的二次方程。在下图中,曲线是这些点的最佳拟合二次回归:
准备工作
安装dsp
包以使用Matrix.LU
,如下所示:
$ cabal install dsp
为了执行二次回归,我们将使用 Wolfram MathWorld 上描述的最小二乘多项式拟合算法,详见mathworld.wolfram.com/LeastSquaresFittingPolynomial.html
。
怎么做…
-
导入以下包:
import Data.Array (listArray, elems) import Matrix.LU (solve)
-
实现二次回归算法,如下面的代码片段所示:
fit d vals = elems $ solve mat vec where mat = listArray ((1,1), (d,d)) $ matrixArray vec = listArray (1,d) $ take d vals matrixArray = concat [ polys x d | x <- [0..fromIntegral (d-1)]] polys x d = map (x**) [0..fromIntegral (d-1)]
-
测试函数如下,使用一些硬编码数据:
main = print $ fit 3 [1,6,17,34,57,86,121,162,209,262,321]
-
三次方程式3x² + 2x + 1的以下值被打印:
$ runhaskell Main.hs [1.0,2.0,3.0]
工作原理…
在这个示例中,设计矩阵mat
与我们希望找到的参数向量相乘,产生响应向量vec
。我们可以用以下方程式可视化每个数组和矩阵:
构建设计矩阵和响应向量后,我们使用dsp
库解决这个矩阵方程,并获得我们多项式的系数列表。
另请参见
如果数据遵循线性趋势,请参考逼近线性回归示例。
从样本获取协方差矩阵
协方差矩阵是一个对称的方阵,其第i行和第j列的元素对应它们之间的相关性。更具体地说,每个元素都是其行和列所代表的变量的协方差。同向运动的变量具有正协方差,而反向运动的变量具有负协方差。
假设我们有三个变量的四组数据,如下表所示:
注意特征 1和特征 3在模式上看起来相似,然而特征 1和特征 2似乎不相关。同样,特征 2和特征 3显著相关。
协方差矩阵将是一个 3 x 3 对称矩阵,其元素如下:
准备工作
使用以下命令在 cabal 中安装hstats
库:
$ cabal install hstats
或者,通过以下步骤安装该包:
-
从
hackage.haskell.org/package/hstats-0.3/hstats-0.3.tar.gz
下载包的源代码。 -
从 cabal 文件
hstats.cabal
中删除haskell98
依赖项。 -
在同一个目录下,运行以下命令行:
$ cabal install
怎么做…
-
如下所示导入
hstats
包:import Math.Statistics
-
根据列表列表创建矩阵,并在其中使用以下代码片段运行
covMatrix
函数:main = do print $ covMatrix matrixArray where matrixArray = [ [1,1,0,0] , [0,1,0,1] , [1,1,0,1] ]
-
检查输出:
$ runhaskell Main.hs [ [ 0.333, 0.000, 0.167] , [ 0.000, 0.333, 0.167] , [ 0.167, 0.167, 0.250] ]
注意无关的特征值为零,正如我们预期的那样。
在列表中查找所有唯一的配对
比较所有项目对是数据分析中的常见习惯用法。本食谱将介绍如何从一个元素列表中创建一个元素对列表。例如,如果有一个列表 [1, 2, 3],我们将创建一个所有可能配对的列表 [(1, 2), (1, 3), (2, 3)]。
注意配对的顺序无关紧要。我们将创建一个独特的元组对列表,以便将列表中的每个项目与其他所有项目进行比较。
它是如何工作的……
创建一个新文件,我们称之为 Main.hs
,并插入以下步骤中解释的代码:
-
导入以下包:
import Data.List (tails, nub, sort)
-
从一个项目列表中构造所有独特的配对,方法如下:
pairs xs = [(x, y) | (x:ys) <- tails (nub xs), y <- ys]
-
打印出以下列表的所有独特配对:
main = print $ pairs [1,2,3,3,4]
-
输出将如下所示:
[(1,2),(1,3),(1,4),(2,3),(2,4),(3,4)]
另见
我们可以将 pairs
算法应用于使用皮尔逊相关系数这个食谱。
使用皮尔逊相关系数
皮尔逊相关系数是一个介于 -1.0 和 1.0 之间的数字,表示两个数值序列的线性关系。值为 1.0 表示强线性相关,-1.0 表示强负相关,0.0 表示这两个序列不相关。
Kiatdd 在en.wikipedia.org/wiki/File:Correlation_coefficient.gif
上创建了一幅非常有信息量的图表,下面是该图:
例如,Nick 是一个相当慷慨的电影评论家,总是给电影打高分。他的朋友 John 可能是一个更为戏剧化的评论家,提供更广泛的评分,但这两个朋友通常总是能在喜欢的电影上达成一致。
我们可以使用皮尔逊相关系数来检测这两个人评分之间有很强的线性关系。
准备工作
使用 cabal 安装 hstats
库,方法如下:
$ cabal install hstats
创建一个文件,每行包含五个星级评分,表示不同人给出的评分。
在我们的示例中,三个人每人给出了五个评分,使用以下命令:
$ cat ratings.csv
4,5,4,4,3
2,5,4,3,1
5,5,5,5,5
注意前两个人的评分趋势相似,但第三个人的评分趋势非常不同。本食谱中的算法将计算皮尔逊相关系数的成对值,并对结果进行排序,以找到评分最相似的两个人。
如何操作…
-
导入以下包:
import Math.Statistics (pearson) import Text.CSV import Data.List (tails, nub, sort)
-
创建以下函数,以计算从列表中获得的相似度:
calcSimilarities (Left err) = error "error parsing" calcSimilarities (Right csv) = head $ reverse $ sort $ zip [ pearson (convertList a) (convertList b) | (a,b) <- pairs csv] $ (pairs csv)
-
将
String
列表转换为Double
列表,方法如下:convertList :: [String] -> [Double] convertList = map read
-
从项目列表中创建所有可能的配对,方法如下:
pairs xs = [(x, y) | (x:ys) <- tails (nub xs), y <- ys]
-
通过查找评分最相似的两个人来测试代码,如以下代码片段所示:
main = do let fileName = "ratings.csv" input <- readFile filename let csv = parseCSV fileName input print $ calcSimilarities csv
-
输出将如下所示:
$ runhaskell Main.hs (0.89442719909999159,(["4","5","4","4","3"],["2","5","4","3","1"]))
评估贝叶斯网络
贝叶斯网络是一个概率依赖图。图中的节点是事件,边表示条件依赖。我们可以通过先验知识构建一个网络,从而发现事件的新概率特性。
我们将使用 Haskell 的概率函数式编程库来评估此类网络并计算有趣的概率。
准备工作
使用 cabal 安装probability
库,如下所示:
$ cabal install probability
我们将表示以下网络。请将以下图形内化,以直观地理解变量名称:
事件C依赖于事件A和B。同时,事件D和E依赖于事件C。通过概率函数式编程库的强大功能,在这个示例中,我们将找到仅根据事件D的信息,计算事件E的概率。
如何进行…
-
导入以下包:
import qualified Numeric.Probability.Distribution as Dist import Numeric.Probability.Distribution ((??), (?=<<), )
-
创建一个辅助函数来定义条件概率,如下所示:
prob p = Dist.choose p True False
-
定义变量 A 的概率,P(A)如下:
a :: Dist.T Rational Bool a = prob 0.2
-
定义变量 B 的概率,P(B)如下:
b :: Dist.T Rational Bool b = prob 0.05
-
定义给定 A 和 B 的情况下 C 的概率,P(C | AB)如下:
c :: Bool -> Bool -> Dist.T Rational Bool c False False = prob 0.9 c False True = prob 0.5 c True False = prob 0.3 c True True = prob 0.1
-
定义在给定 C 的情况下 D 的概率,P(D | C)如下:
d :: Bool -> Dist.T Rational Bool d False = prob 0.1 d True = prob 0.4
-
定义在给定 C 的情况下 E 的概率,P(E | C)如下:
e :: Bool -> Dist.T Rational Bool e False = prob 0.5 e True = prob 0.2
-
定义网络的数据结构,如下所示:
data Network = N {aVal :: Bool , bVal :: Bool , cVal :: Bool , dVal :: Bool , eVal :: Bool } deriving (Eq, Ord, Show)
-
根据前面的图形构建网络:
bNetwork :: Dist.T Rational Network bNetwork = do a' <- a b' <- b c' <- c a' b' d' <- d c' e' <- e c' return (N a' b' c' d' e')
-
计算在给定 D 的情况下 E 的概率,P(E | D)如下:
main = print $ eVal ?? dVal ?=<< bNetwork
-
输出以分数表示如下:
$ runhaskell Main.hs 3643 % 16430
创建扑克牌的数据结构
许多概率和统计问题都是通过扑克牌提出的。在这个示例中,我们将创建一个数据结构和有用的函数来处理扑克牌。
标准扑克牌共有 52 张,每张卡牌都有以下四种花色之一:
-
黑桃
-
红桃
-
方块
-
梅花
此外,每张卡牌都有 13 个等级中的一个,如下所示:
-
整数范围从 2 到 10(包括 2 和 10)
-
J(杰克)
-
Q(皇后)
-
K(国王)
-
A(王牌)
准备工作
使用 cabal 安装probability
库,如下所示:
$ cabal install probability
查看关于集合的probability
包的示例代码,网址:hackage.haskell.org/package/probability-0.2.4/docs/src/Numeric-Probability-Example-Collection.html
。
本示例 heavily 基于链接中的概率示例。
如何进行…
-
导入以下包:
import qualified Numeric.Probability.Distribution as Dist import Numeric.Probability.Distribution ((??)) import Control.Monad.Trans.State (StateT(StateT, runStateT), evalStateT) import Control.Monad (replicateM) import Data.List (delete)
-
创建卡牌花色的数据结构,如下所示:
data Suit = Club | Spade | Heart | Diamond deriving (Eq,Ord,Show,Enum)
-
创建一副牌的等级数据结构,如下所示:
data Rank = Plain Int | Jack | Queen | King | Ace deriving (Eq,Ord,Show)
-
定义一个快捷类型,将卡牌表示为等级和花色的元组,如下所示:
type Card = (Rank,Suit)
-
如下描述普通牌:
plains :: [Rank] plains = map Plain [2..10]
-
如下描述面牌:
faces :: [Rank] faces = [Jack,Queen,King,Ace]
-
创建一个辅助函数,如下所示,用于检测是否是面牌:
isFace :: Card -> Bool isFace (r,_) = r `elem` faces
-
创建一个辅助函数,如下所示,用于检测是否是普通牌:
isPlain :: Card -> Bool isPlain (r,_) = r `elem` plains
-
定义所有等级牌如下:
ranks :: [Rank] ranks = plains ++ faces
-
定义花色牌如下:
suits :: [Suit] suits = [Club, Spade, Heart, Diamond]
-
根据等级和花色创建一副扑克牌,如下所示:
deck :: [Card] deck = [ (r,s) | r <- ranks, s <- suits ]
-
创建一个帮助函数如下以选择列表中的项目进行概率测量:
selectOne :: (Fractional prob, Eq a) => StateT ([a]) (Dist.T prob) a selectOne = StateT $ Dist.uniform . removeEach
-
创建一个函数如下以从牌组中选择一些卡片:
select :: (Fractional prob, Eq a) => Int -> [a] -> Dist.T prob [a] select n = evalStateT (replicateM n selectOne)
-
创建一个帮助函数如下,以从列表中移除每个项目:
removeEach xs = zip xs (map (flip delete xs) xs)
-
使用创建的概率函数测试卡牌组如下:
main = print $ Dist.just [(Plain 3, Heart), (Plain 3, Diamond)] ?? select 2 deck
-
从牌组选择这两张牌的概率如下:
3.770739064856712e-4
使用马尔可夫链生成文本
马尔可夫链是一个系统,根据当前条件预测系统未来的结果。我们可以在数据语料库上训练马尔可夫链,通过跟随状态生成新的文本。
链的图形表示如下图所示:
节点 E 以 70%的概率结束在节点 A,并以 30%的概率保持不变
准备好
使用 cabal 安装markov-chain
库如下:
$ cabal install markov-chain
下载一个大语料库的文本,并命名为big.txt
。在这个示例中,我们将使用从norvig.com/big.txt
下载的文本。
如何做…
-
导入以下包:
import Data.MarkovChain import System.Random (mkStdGen)
-
在大文本输入上训练马尔可夫链,然后按以下方式运行它:
main = do rawText <- readFile "big.txt" let g = mkStdGen 100 putStrLn $ "Character by character: \n" putStrLn $ take 100 $ run 3 rawText 0 g putStrLn $ "\nWord by word: \n" putStrLn $ unwords $ take 100 $ run 2 (words rawText)0 g
-
我们可以运行马尔可夫链并查看输出如下:
$ runhaskell Main.hs Generated character by character: The evaturn bring everice Ana Paciously skuling from to was fing, of rant of and sway. 5\. Whendent Generated word by word: The Project gratefully accepts contributions of money, though there was a brief word, showing that he would do so. He could hear all that she had to reply; the room scanned Princess Mary's heartbeat so violently at this age, so dangerous to life, by the friends of the Russians, was trying to free his serfs--and that till the eggs mature, when by their Creator with certain small vessels but no me...." And the cavalry, Colonel, but I don't wish to know which it has a fit, and there was a very large measure, attributed to eating this root. But
它是如何工作的…
代码打印我们由语料库训练的文本,该文本被馈送到马尔可夫链中。
在第一个字符级马尔可夫链中,它试图根据前三个字母生成下一个字母。请注意,大多数短语毫无意义,甚至有些标记不是英语单词。
第二个马尔可夫链是逐词生成的,只基于前两个词进行推断。正如我们所见,它更自然地模拟了英语短语。
这些文本纯粹通过评估概率生成。
从列表创建 n-gram
n-gram 是连续n个项目的序列。例如,在以下数字序列[1, 2, 5, 3, 2]中,一个可能的 3-gram 是[5, 3, 2]。
n-gram 在计算概率表以预测下一个项目时非常有用。在这个示例中,我们将从项目列表中创建所有可能的n-gram。马尔可夫链可以通过这个示例中的n-gram 计算轻松训练。
如何做…
-
定义n-gram 函数如下以从列表生成所有可能的n-gram:
ngram :: Int -> [a] -> [[a]] ngram n xs | n <= length xs = take n xs : ngram n (drop 1 xs) | otherwise = []
-
在样本列表上测试如下:
main = print $ ngram 3 "hello world"
-
打印的 3-gram 如下:
["hel","ell","llo","lo ","o w"," wo","wor","orl","rld"]
创建一个神经网络感知器
感知器是一个线性分类器,使用标记数据收敛到其答案。给定一组输入及其相应的预期输出,感知器试图线性分离输入值。如果输入不是线性可分的,则算法可能不会收敛。
在这个示例中,我们将处理以下数据列表:
[(0,0), (0,1), (1,0), (1,1)].
每个项目都标有预期输出如下:
-
(0,0)
预期输出0
-
(0,1)
预期输出0
-
(1,0)
预期输出0
-
(1,1)
期望输出1
从图形上看,我们试图找到一条能够分离这些点的线:
准备就绪
通过以下方式回顾感知器的概念:
-
阅读关于感知器的维基百科文章,网址为
en.wikipedia.org/wiki/Perceptron
-
浏览 Moresmau 提供的 Haskell 实现,网址为
jpmoresmau.blogspot.com/2007/05/perceptron-in-haskell.html
如何做到…
-
导入
replicateM
、randomR
和getStdRandom
来处理神经网络中的随机数生成,如下所示:import Control.Monad (replicateM) import System.Random (randomR, getStdRandom)
-
定义类型来帮助描述传入每个辅助方法的变量,如下所示:
type Inputs = [Float] type Weights = [Float] type Threshold = Float type Output = Float type Expected = Float type Actual = Float type Delta = Float type Interval = Int type Step = (Weights, Interval)
-
创建一个函数来生成神经元的输出值,该函数接受一系列输入、相应的权重和一个阈值。如果权重向量与输入向量的点积大于阈值,则神经元触发输出
1
,否则输出0
,如以下代码片段所示:output :: Inputs -> Weights -> Threshold -> Output output xs ws t | (dot xs ws) > t = 1 | otherwise = 0 where dot as bs = sum $ zipWith (*) as bs
-
创建一个函数,根据期望结果和实际结果调整神经元的权重。权重使用学习规则进行更新,如以下代码片段所示:
adjustWeights :: Inputs -> Weights -> Expected -> Actual -> Weights adjustWeights xs ws ex ac = add ws delta where delta = map (err * learningRate *) xs add = zipWith (+) err = ex - ac learningRate = 0.1
-
步进一次感知器循环来更新权重,如下所示。对于这个示例,假设每个神经元的阈值为 0.2:
step :: Inputs -> Weights -> Expected -> Weights step xs ws ex = adjustWeights xs ws ex (output xs ws t) where t = 0.2
-
创建一个辅助函数如下,计算每步的权重变化:
epoch :: [(Inputs, Expected)] -> Weights -> (Weights, Delta) epoch inputs ws = (newWeights, delta) where newWeights = foldl (\acc (xs, ex) -> step xs acc ex) ws inputs delta = (sum (absSub newWeights ws)) / length' ws absSub as bs = map abs $ zipWith (-) as bs length' = fromIntegral . length
-
运行
epoch
步骤,直到权重收敛。权重收敛通过注意到权重首次不再显著变化来检测。这在以下代码片段中有所展示:run :: [(Inputs, Expected)] -> Weights -> Interval -> Step run inputs ws n | delta == 0.0 = (newWeights, n) | otherwise = run inputs newWeights (n+1) where (newWeights, delta) = epoch inputs ws
-
初始化权重向量,如下所示:
initialWeights :: Int -> IO [Float] initialWeights nb = do let interval = randomR (-0.5,0.5) (replicateM nb (getStdRandom interval))
-
测试感知器网络以分离一个与运算的布尔结构,如下所示:
main :: IO () main = do w <- initialWeights 2 let (ws,i) = run [ ([0,0],0) , ([0,1],0) , ([1,0],0) , ([1,1],1) ] w 1 print (ws,i)
-
有效的输出可能是:
([0.17867908,3.5879448e-1],8)
我们可以验证该输出是正确的,因为权重的总和大于阈值 0.2,而每个权重值本身都小于 0.2。因此,输出将仅在输入为(1, 1)时触发,正如所期望的那样。
第八章 聚类与分类
本章展示了智能地聚类和分类数据的算法:
-
实现 k-means 聚类算法
-
实现层次聚类
-
使用层次聚类库
-
寻找聚类的数量
-
按词根对单词进行聚类
-
对词汇进行词性分类
-
在文本语料库中识别关键字
-
训练一个词性标注器
-
实现决策树分类器
-
实现 k-最近邻分类器
-
使用 Graphics.EasyPlot 可视化数据点
介绍
计算机算法在分析大型数据集方面变得越来越优秀。随着性能的提升,它们检测数据中有趣模式的能力也在增强。
本章前几个算法展示了如何查看成千上万的数据点并识别聚类。聚类只是通过数据点之间的紧密程度来定义的一个集合。这个“紧密度”的度量完全由我们决定。其中一种最常用的紧密度度量是欧几里得距离。
我们可以通过仰望夜空并指向看似聚集在一起的星星来理解聚类。我们的祖先发现将星星“聚集”命名为星座很方便。我们将会在数据点的“天空”中找到属于自己的星座。
本章还重点介绍了词汇的分类。我们将根据词语的词性以及主题为其标注标签。
我们将实现自己的决策树来分类实际数据。最后,我们将使用绘图库可视化聚类和数据点。
实现 k-means 聚类算法
k-means 聚类算法将数据划分为 k 个不同的组。这些 k 个组叫做聚类,而这些聚类的位置是通过迭代调整的。我们计算组内所有点的算术平均值,以获得一个质心点,并用它替代之前的聚类位置。
希望通过这个简明的解释,k-means 聚类这个名字不再听起来完全陌生。了解更多关于该算法的最佳途径之一是在 Coursera 上:class.coursera.org/ml-003/lecture/78
。
如何做到这一点……
创建一个新文件,命名为Main.hs
,并执行以下步骤:
-
导入以下内置库:
import Data.Map (Map) import qualified Data.Map as Map import Data.List (minimumBy, sort, transpose) import Data.Ord (comparing)
-
定义如下所示的点的类型同义词:
type Point = [Double]
-
定义两点之间的欧几里得距离函数:
dist :: Point -> Point -> Double dist a b = sqrt $ sum $ map (²) $ zipWith (-) a b
-
定义 k-means 算法中的分配步骤。每个点将被分配到其最接近的质心:
assign :: [Point] -> [Point] -> Map Point [Point] assign centroids points = Map.fromListWith (++) [(assignPoint p, [p]) | p<- points] where assignPoint p = minimumBy (comparing (dist p)) centroids
-
定义 k-means 算法中的重新定位步骤。每个质心都被重新定位到其对应点的算术平均值:
relocate :: Map Point [Point] -> Map Point [Point] relocate centroidsMap = Map.foldWithKey insertCenter Map.empty centroidsMap where insertCenter _ ps m = Map.insert (center ps) ps m center [] = [0,0] center ps = map average (transpose ps) average xs = sum xs / fromIntegral (length xs)
-
反复运行 k-means 算法,直到质心不再移动:
kmeans :: [Point] -> [Point] -> [Point] kmeans centroids points = if converged then centroids else kmeans (Map.keys newCentroidsMap) points where converged = all (< 0.00001) $ zipWith dist (sort centroids) (Map.keys newCentroidsMap) newCentroidsMap = relocate (assign centroids points) equal a b = dist a b < 0.00001
-
通过几个硬编码点测试聚类。实现 k-means 的通常方法是随机选择起始质心。但是,在这个例子中,我们将简单地采用前 k 个点:
main = do let points = [ [0,0], [1,0], [0,1], [1,1] , [7,5], [9,6], [8,7] ] let centroids = kmeans (take 2 points) points print centroids
-
当算法收敛后,最终的质心将如下所示:
$ runhaskell Main.hs [[0.5,0.5],[8.0,6.0]]
工作原理…
该算法重复执行两个过程,直到找到聚类。第一个过程是通过将每个点分配到其最近的质心来分区点。下面的图表显示了数据分配步骤。最初有三个由星号、正方形和圆圈表示的质心,围绕三个不同的点。算法的第一部分将每个点分配到相应的质心。
接下来的步骤是将质心重新定位到它们对应点的中心或算术平均值。在下面的图表中,计算了每个聚类的算术平均值,并将质心移至新的中心:
此算法持续进行,直到质心不再移动。每个点的最终分类是其所属的聚类。
还有更多…
尽管易于实现和理解,但是这种算法有几个局限性。k-means 聚类算法的输出对选择的初始质心敏感。此外,使用欧氏距离度量迫使聚类仅通过圆形区域描述。k-means 聚类的另一个局限是用户必须指定初始聚类数 k。用户应该可视化数据并根据自己的判断确定算法开始前的聚类数量。此外,算法的收敛条件对于特殊边界情况是一个问题。
另请参见
对于另一种类型的聚类算法,请参阅下一个关于实现分层聚类的章节。
实现分层聚类
另一种聚类数据的方法是首先假设每个数据项是其自身的一个独立聚类。然后,我们可以退一步,合并两个最近的聚类。这个过程形成了一个聚类的层次结构。
举个例子,可以类比于岛屿和水平面的类比。岛屿仅仅是被水包围的山顶。想象一下我们在海洋中散布着岛屿。如果我们慢慢降低海洋水位,两个附近的小岛屿将会合并成一个更大的岛屿,因为它们与同一山脉连接。我们可以在得到所需数量的较大岛屿时停止水位的下降。
如何做…
在一个新文件中,我们命名为Main.hs
,插入以下代码:
-
导入内置函数:
import Data.Map (Map, (!), delete) import qualified Data.Map as Map import Data.Ord (comparing) import Data.List (sort, tails, transpose, minimumBy)
-
为点定义一个类型同义词:
type Point = [Double]
-
定义一个方便的函数来计算点列表的算术平均值:
center :: [Point] -> Point center points = map average (transpose points) where average xs = sum xs / fromIntegral (length xs)
-
组合两个最接近的聚类:
merge :: Map Point [Point] -> Map Point [Point] merge m = Map.insert (center [a,b]) ((m ! a) ++ (m ! b)) newM where (a,b) = nearest (Map.keys m) newM = Map.delete b (Map.delete a m) equal a b = dist a b < 0.00001 dist a b = sqrt $ sum $ map (²) $ zipWith (-) a b nearest points = minimumBy (comparing (uncurry dist)) [(a, b) | (a : rest) <- tails points, b <- rest]
-
运行分层算法直到有 k 个聚类:
run :: Int -> Map Point [Point] -> Map Point [Point] run k m = if length (Map.keys m) == k then m else run k (merge m)
-
初始化,使每个点都是其自身的一个聚类:
initialize :: [Point] -> Map Point [Point] initialize points = foldl (\m p -> Map.insert p [p] m) Map.empty points
-
在一些输入上测试聚类算法:
main = do let points = [ [0,0], [1,0], [0,1], [1,1] , [7,5], [9,6], [8,7]] let centroids = Map.keys $ run 2 (initialize points) print centroids
-
算法将输出以下质心:
$ runhaskell Main.hs [[0.5,0.5],[7.75,5.75]]
如何工作……
层次聚类的实现方法主要有两种。本示例中的算法实现了凝聚型自底向上的方法。每个点最初被视为一个聚类,在每一步中,两个最接近的聚类合并在一起。然而,另一种实现方法是自顶向下的分裂型方法,每个点最开始都在一个大聚类中,并逐步分裂成多个小聚类。
在这个示例中,我们首先假设每个点都是自己的聚类。然后,我们退后一步,将两个最接近的聚类合并。这一过程重复进行,直到达到期望的收敛状态。在我们的示例中,一旦得到正好两个聚类,我们就停止。下图展示了层次聚类算法的三次迭代过程:
还有更多……
与大多数聚类算法一样,距离度量的选择对结果有很大影响。在这个示例中,我们假设使用欧几里得度量,但根据数据的不同,可能应该使用曼哈顿距离或余弦相似度。
另见
对于非层次聚类算法,请参见之前的示例实现 k-means 聚类算法。
使用层次聚类库
我们将使用层次聚类方法将一组点聚合在一起。我们从假设每个点都是自己的聚类开始。两个最接近的聚类合并在一起,算法重复这一过程,直到满足停止条件为止。在这个算法中,我们将使用一个库来执行层次聚类,直到剩下指定数量的聚类。
准备开始
使用 cabal 安装层次聚类包,方法如下(文档请参见 hackage.haskell.org/package/hierarchical-clustering
):
$ cabal install hierarchical-clustering
如何实现……
将以下代码插入一个新文件,我们称之为 Main.hs
:
-
导入所需的库:
import Data.Clustering.Hierarchical
-
定义一个点的数据类型:
data Point = Point [Double] deriving Show
-
定义欧几里得距离度量:
dist :: Point -> Point -> Distance dist (Point a) (Point b) = sqrt $ sum $ map (²) $ zipWith (-) a b
-
输出聚类结果:
printCluster :: Dendrogram Point -> Double -> IO () printCluster clusters cut = do let es = map elements $ clusters `cutAt` cut mapM_ print es
-
在一些点上测试聚类算法:
main = do let points = map Point [ [0,0], [1,0], [0,1], [1,1] , [7,5], [9,6], [8,7] ] let clusters = dendrogram SingleLinkage points dist printCluster clusters 2.0
-
每个聚类输出为点的列表:
[Point [0.0,1.0], Point [1.0,0.0], Point [0.0,0.0], Point [1.0,1.0]] [Point [7.0,5.0]] [Point [9.0,6.0], Point [8.0,7.0]]
如何工作……
dendogram
函数的类型为 Linkage -> [a] -> (a -> a -> Distance) -> Dendogram a
。链接方法描述了如何计算距离。在这个示例中,我们使用 SingleLinkage
作为第一个参数,这意味着两个聚类之间的距离是它们所有元素之间的最小距离。
第二个参数是点的列表,后面跟着距离度量。该函数的结果是一个树状图,也叫做层次树形图。我们使用定义的 printCluster
函数来显示聚类结果。
还有更多……
该库中其他类型的链接包括以下内容,并且在 Hackage 上有相应的描述:
-
SingleLinkage
:这是两个聚类之间的最小距离。"O(n²)时间和 O(n)空间,使用 SLINK 算法。该算法在空间和时间上都是最优的,并且与使用距离矩阵的朴素算法得出的结果相同。"
-
CompleteLinkage
:这是两个聚类之间的最大距离。"O(n³)时间和 O(n²)空间,使用带距离矩阵的朴素算法。如果需要更高性能,使用 CLINK。"
-
使用CLINK的完全连接与之前的连接类型相同,只是它使用了一种更快但不总是最优的算法。
"O(n²)时间和 O(n)空间,使用 CLINK 算法。请注意,该算法并不总是给出与使用距离矩阵的朴素算法相同的结果,但它要快得多。"
-
UPGMA 是两个聚类之间的平均距离。
"O(n³)时间和 O(n²)空间,使用带距离矩阵的朴素算法。"
-
最后,FakeAverageLinkage 与之前的 UPGMA 连接相似,但在计算时对两个聚类赋予相同的权重。
"O(n³)时间和 O(n²)空间,使用带距离矩阵的朴素算法。"
另见
若要使用我们自己的层次聚类算法,请参阅之前的食谱实现层次聚类。
寻找聚类数目
有时,我们并不知道数据集中聚类的数量,但大多数聚类算法需要事先知道这一信息。一种找到聚类数目方法是运行聚类算法,尝试所有可能的聚类数目,并计算每个聚类的平均方差。然后,我们可以绘制聚类数目的平均方差图,并通过找到曲线的第一次波动来确定聚类的数目。
准备工作
查看标题为实现 k 均值聚类算法的 k-means 食谱。我们将使用在该食谱中定义的kmeans
和assign
函数。
从 cabal 安装 Statistics 包:
$ cabal install statistics
如何操作…
创建一个新文件并插入以下代码。我们将这个文件命名为Main.hs
。
-
导入
variance
函数和辅助函数fromList
:import Statistics.Sample (variance) import Data.Vector.Unboxed (fromList)
-
计算每个聚类的方差平均值:
avgVar points centroids = avg [variance . fromList $ map (dist c) ps | (c, ps) <- Map.assocs m] where m = assign centroids points avg xs = (sum xs) / (fromIntegral (length xs))
-
在
main
中定义一个点的列表。注意这里似乎有三个聚类:main = do let points = [ [0,0], [1,0], [0,1] , [20,0], [21,0], [20,1] , [40,5], [40,6], [40,8] ]
-
获取每一组聚类的方差平均值:
let centroids = [ kmeans (take k points) points | k <- [1..length points] ] let avgVars = map (avgVar points) centroids print avgVars
-
输出将是一个数字列表。一旦绘制出来,我们可以看到聚类的数目是三个,这出现在曲线的膝部或局部最大值之前,如下图所示:
按照词汇语素对单词进行聚类
看起来相似的单词可以很容易地被聚类在一起。lexeme-clustering 包中的聚类算法基于 Janicki 的研究论文《用于无监督学习词法形态的词素聚类算法》。可以通过以下网址直接访问该论文:skil.informatik.uni-leipzig.de/blog/wp-content/uploads/proceedings/2012/Janicki2012.37.pdf
。
准备工作
本教程需要互联网连接,以便从 GitHub 下载包。
如何做……
按照以下步骤安装并使用该库:
-
从 GitHub 获取 lexeme-clustering 库。如果已安装 Git,输入以下命令,否则从
github.com/BinRoot/lexeme-clustering/archive/master.zip
下载:$ git clone https://github.com/BinRoot/lexeme-clustering
-
切换到库的目录:
$ cd lexeme-clustering/
-
安装该包:
$ cabal install
-
创建一个每行包含不同单词的输入文件:
$ cat input.txt mama papa sissy bro mother father grandfather grandmother uncle mommy daddy ma pa mom dad sister brother
-
在输入文件上运行 lexeme-clustering 算法:
$ dist/build/lexeme-clustering/lexeme-clustering input.txt
-
得到的输出聚类结果将会展示出来:
# Clustering bro, brother dad, daddy grandfather, grandmother father, ma, mama, mom, mommy, mother, pa, papa sissy, sister uncle
它是如何工作的……
通过仔细观察每个单词的词素,即最小的有意义成分,相关的词汇会被聚集在一起。
这是基于此算法的研究论文摘要中的一小段摘录:
“最初,构建一个单词的前缀树,每个前缀树中的节点都被视为词干的候选。根据互信息对其出现的后缀进行聚类,以识别屈折范式。”
另见
对数据点进行聚类,请参考之前的算法:实现 k-means 聚类算法、实现层次聚类,以及使用层次聚类库。
对单词词性进行分类
本教程将演示如何识别句子中每个单词的词性。我们将使用一个非常实用的库叫做chatter,它包含了很多有用的自然语言处理(NLP)工具。你可以从 Hackage 获取该库,地址为hackage.haskell.org/package/chatter
。
自然语言处理(NLP)是将人类语言嵌入到计算机中的研究领域。我们日常说的或写的语言对我们来说似乎是显而易见的,但要从单词中提取意义仍然是计算机面临的一个难题。
准备工作
使用 cabal 安装 NLP 库:
cabal install chatter
如何做……
在一个新文件中,我们命名为Main.hs
,输入以下源代码:
-
导入词性库和包函数:
import NLP.POS import Data.Text (pack)
-
获取库提供的默认标注器:
main = do tagger <- defaultTagger
-
将
tag
函数与标注器和文本一起使用,查看每个单词对应的词性:let text = pack "The best jokes have no punchline." print $ tag tagger text
-
输出将是单词与其词性的关联列表:
[[ ("The", Tag "at"), ("best", Tag "jjt"), ("jokes", Tag "nns"), ("have", Tag "hv"), ("no", Tag "at"), ("punchline",Tag "nn"), (".",Tag ".") ]]
它是如何工作的……
词性标注器是从文本语料库中训练出来的。在这个示例中,我们使用库提供的默认标注器,它会在包中的以下目录 data/models/brown-train.model.gz
上进行训练。这个语料库被称为布朗大学现代美式英语标准语料库,创建于 1960 年代。
每个缩写词的定义,如 at、jjt
或 nns
,可以在 en.wikipedia.org/wiki/Brown_Corpus#Part-of-speech_tags_used
找到。
还有更多……
我们还可以通过从文件路径加载标注器来训练我们自己的词性标注器,loadTagger :: FilePath -> IO POSTagger
。
另见:
要将单词分类为除词性以外的其他类别,请参见下一节 识别文本语料库中的关键字。
识别文本语料库中的关键字:
一种预测段落或句子主题的方法是通过识别单词的含义。虽然词性可以为每个单词提供一些信息,但它们仍然无法揭示该单词的含义。在这个食谱中,我们将使用 Haskell 库将单词标记为如 PERSON
、CITY
、DATE
等主题。
准备工作:
本食谱需要互联网连接来下载 sequor
包。
通过 cabal 安装:
$ cabal install sequor --prefix=`pwd`
否则,按照以下步骤手动安装:
-
通过打开浏览器并访问以下网址获取 sequor 库的最新版本:
hackage.haskell.org/package/sequor
。 -
在 Downloads 部分,下载 cabal 源代码包。
-
提取内容:
-
在 Windows 上,使用 7-Zip 是最简单的,它是一款易于使用的文件压缩工具。通过访问
www.7-zip.org
将其安装到你的机器上。然后使用 7-Zip 提取 tarball 文件的内容。 -
在其他操作系统上,运行以下命令以提取 tarball 文件。替换命令中的数字为下载的正确版本号,因为可能会有新版本(例如 0.7.3)发布:
$ tar –zxvf sequor-0.7.2.tar.gz
-
-
进入该目录:
$ cd sequor-0.7.2
-
确保阅读
README
文件:$ cat README.*
-
使用以下 Cabal 命令安装该库:
$ cabal install –-prefix=`pwd`
如何操作……
我们将设置一个输入文件,将其输入到程序中。
-
使用 CoNLL 格式创建一个
input.txt
文件,每行一个标记,句子之间用空行分隔:$ cat input.txt On Tuesday Richard Stallman will visit Charlottesville , Virginia in the United States
-
现在运行输入文件的词标注:
$ bin/seminer en < input.txt > output.txt
-
结果会保存在
output.txt
文件中。打开文件并查看找到的相应标签:$ cat output.txt O B-DATE B-PERSON I-PERSON O O B-GPE:CITY O B-GPE:STATE_PROVINCE O O B-GPE:COUNTRY I-GPE:COUNTRY
它是如何工作的……
该库使用了 Collins 的序列感知机,该感知机基于他于 2002 年发表的论文《隐马尔可夫模型的判别训练方法:感知机算法的理论与实验》。他的个人网站 (www.cs.columbia.edu/~mcollins/
) 包含了关于设计本食谱中使用的算法的详细笔记。
另见:
要使用现有的词性标注器,请参阅之前的食谱 词性分类。要训练我们自己的词性标注器,请参阅下一个食谱 训练词性标注器。
训练词性标注器
我们将使用 Haskell 库 sequor 来训练我们自己的词性标注器。然后,我们可以在自己的输入数据上使用这个新训练的模型。
准备就绪
请参阅之前食谱中的 准备就绪 部分。
如何实现…
在一个新文件中,我们将其命名为 Main.hs
,输入以下源代码:
-
使用
sequor
可执行文件来训练词性标注器:-
sequor
的第一个参数将是train
,表示我们将要训练一个标注器。 -
下一个参数是模板文件,
data/all.features
。 -
然后提供训练文件
data/train.conll
。 -
我们需要提供的最后一个文件路径是保存已训练模型的位置。
-
我们可以使用
-rate
标志来指定学习率。 -
可以使用
-beam
标志修改波束大小。 -
使用
-iter
标志更改迭代次数。 -
使用
-hash
标志代替特征字典进行哈希处理。 -
使用
-heldout
标志提供一个持有数据的路径。 -
一个使用
sequor
命令的示例如下:$ ./bin/sequor train data/all.features data/train.conll \ model --rate 0.1 --beam 10 --iter 5 --hash \ --heldout data/devel.conll
-
-
在样本输入上测试已训练的模型:
$ ./bin/sequor predict model < data/test.conll > \ data/test.labels
-
输出
test.labels
文件的前几行将是:B-NP I-NP B-PP B-NP I-NP O B-VP B-NP B-VP B-NP
它是如何工作的…
该库使用 Collins 的序列感知器,该感知器基于 2002 年发布的论文 "Discriminative Training Methods for Hidden Markov Models: Theory and Experiments with Perceptron Algorithms"。Hackage 文档可以在 hackage.haskell.org/package/sequor
找到。
另见
要使用现有的词性标注器,请参阅之前的食谱 词性分类。
实现决策树分类器
决策树是一种有效的分类数据的模型。树中每个节点的子节点代表我们正在分类项目的特征。从树的根节点向下遍历到叶节点表示项目的分类。通常希望创建一个尽可能小的树来表示大量的数据样本。
在这个食谱中,我们在 Haskell 中实现了 ID3 决策树算法。它是最容易实现的算法之一,并能产生有用的结果。然而,ID3 并不保证最优解,相比其他算法,它在计算上可能效率较低,并且只支持离散数据。虽然这些问题可以通过更复杂的算法如 C4.5 来解决,但本食谱中的代码足以让你快速启动并运行一个有效的决策树。
准备就绪
创建一个 CSV 文件,表示数据样本。最后一列应该是分类。将此文件命名为 input.csv
。
天气数据由四个属性表示,即天况、温度、湿度和风速。最后一列表示是否适合户外活动。
导入 CSV 辅助库:
$ cabal install csv
如何做…
将此代码插入到一个新文件中,我们称之为Main.hs
:
-
导入内置库:
import Data.List (nub, elemIndices) import qualified Data.Map as M import Data.Map (Map, (!)) import Data.List (transpose) import Text.CSV
-
定义一些类型别名,以更好地理解传递的数据:
type Class = String type Feature = String type Entropy = Double type DataSet = [([String], Class)]
-
定义主函数,读取 CSV 文件并处理任何错误:
main = do rawCSV <- parseCSVFromFile "input.csv" either handleError doWork rawCSV handleError = error "invalid file"
-
如果文件成功读取,去除任何无效的 CSV 记录并从中构建决策树:
doWork csv = do let removeInvalids = filter (\x -> length x > 1) let myData = map (\x -> (init x, last x)) $ removeInvalids csv print $ dtree "root" myData
-
定义辅助函数,将
DataSet
元组拆分为样本列表或类别列表:samples :: DataSet -> [[String]] samples d = map fst d classes :: DataSet -> [Class] classes d = map snd d
-
计算一组值的熵:
entropy :: (Eq a) => [a] -> Entropy entropy xs = sum $ map (\x -> prob x * info x) $ nub xs where prob x = (length' (elemIndices x xs)) / (length' xs) info x = negate $ logBase 2 (prob x) length' xs = fromIntegral $ length xs
-
按特征拆分一个属性:
splitAttr :: [(Feature, Class)] -> Map Feature [Class] splitAttr fc = foldl (\m (f,c) -> M.insertWith (++) f [c] m) M.empty fc
-
获取从属性特征拆分后的每个熵值:
splitEntropy :: Map Feature [Class] -> M.Map Feature Entropy splitEntropy m = M.map entropy m
-
计算通过特征拆分属性后的信息增益:
informationGain :: [Class] -> [(Feature, Class)] -> Double informationGain s a = entropy s - newInformation where eMap = splitEntropy $ splitAttr a m = splitAttr a toDouble x = read x :: Double ratio x y = (fromIntegral x) / (fromIntegral y) sumE = M.map (\x -> (fromIntegral.length) x / (fromIntegral.length) s) m newInformation = M.foldWithKey (\k a b -> b + a*(eMap!k)) 0 sumE
-
确定哪个属性贡献了最高的信息增益:
highestInformationGain :: DataSet -> Int highestInformationGain d = snd $ maximum $ zip (map ((informationGain . classes) d) attrs) [0..] where attrs = map (attr d) [0..s-1] attr d n = map (\(xs,x) -> (xs!!n,x)) d s = (length . fst . head) d
-
定义我们即将构建的决策树的数据结构:
data DTree = DTree { feature :: String , children :: [DTree] } | Node String String deriving Show
-
按信息增益最高的属性拆分数据集:
datatrees :: DataSet -> Map String DataSet datatrees d = foldl (\m (x,n) -> M.insertWith (++) (x!!i) [((x `dropAt` i), fst (cs!!n))] m) M.empty (zip (samples d) [0..]) where i = highestInformationGain d dropAt xs i = let (a,b) = splitAt i xs in a ++ drop 1 b cs = zip (classes d) [0..]
-
定义辅助函数来确定列表中的所有元素是否相等。我们用这个来检查是否需要进一步拆分数据集,通过检查其类别是否相同:
allEqual :: Eq a => [a] -> Bool allEqual [] = True allEqual [x] = True allEqual (x:xs) = x == (head xs) && allEqual xs
-
从标签和样本数据集构建决策树:
dtree :: String -> DataSet -> DTree dtree f d | allEqual (classes d) = Node f $ head (classes d) | otherwise = DTree f $ M.foldWithKey (\k a b -> b ++ [dtree k a] ) [] (datatrees d)
-
运行以下代码查看打印出的树形结构:
DTree { feature = "root" , children = [ DTree { feature = "Sunny" , children = [ Node "Normal" "Yes" , Node "High" "No" ] , DTree { feature = "Rain" , children = [ Node "Weak" "Yes" , Node "Strong" "No" ] } , Node "Overcast" "Yes" ] }
可以使用以下图表进行可视化:
它是如何工作的……
ID3 算法使用香农熵的概念,通过最大化信息增益的属性将样本集划分开来。这个过程会递归重复,直到处理的是同一分类的样本,或者直到属性用完为止。
在信息论领域,熵是不可预测性的度量。公平的硬币比偏向的硬币具有更高的熵。熵可以通过取信息内容的期望值来计算,其中随机变量 X 的信息内容形式为ln(P(X))。当方程中的对数以 2 为底时,熵的单位称为比特。
信息增益是从先前状态到新状态的熵变化。其公式为IG = H[1] – H[2],其中H[1]是样本的原始熵,H[2]是在给定属性分裂后的新熵。
实现 k-最近邻分类器
一种简单的分类方法是仅查看其邻近数据。k-最近邻算法查看与当前项距离最近的 k 个项。然后,该项将被分类为其 k 个邻居中最常见的分类。这种启发式方法在各种分类任务中都表现得非常有前景。
在本教程中,我们将实现使用k-d 树数据结构的 k-最近邻算法,k-d 树是一种具有特殊属性的二叉树,允许在 k 维空间中高效表示点。
假设我们有一个新潮网站的 Web 服务器。每次有人请求网页时,我们的 Web 服务器将获取文件并展示页面。然而,机器人可以轻松地向 Web 服务器发送数千个请求,可能导致拒绝服务攻击。在本教程中,我们将对 Web 请求进行分类,判断请求者是人类还是机器人。
准备工作
使用 cabal 安装KdTree
、CSV
和iproute
包:
$ cabal install KdTree
$ cabal install CSV
$ cabal install iproute
创建一个包含 IP 地址和自上次访问以来经过的秒数的 CSV 文件。每条 CSV 记录的最后一列应为分类 Human 或 Bot。我们将文件命名为input.csv
。
如何操作…
创建名为Main.hs
的新文件后,执行以下步骤:
-
导入以下包:
import Data.Trees.KdTree import Data.IP (IPv4, fromIPv4) import Text.CSV import qualified Data.Map as M import Data.Maybe (fromJust)
-
将 IPv4 地址字符串转换为 32 位表示:
ipToNum :: String -> Double ipToNum str = fromIntegral $ sum $ zipWith (\a b -> a * 256^b) ns [0..] where ns = reverse $ fromIPv4 (read str :: IPv4)
-
从 CSV 文件中解析数据,获取点及其相关分类:
parse :: [Record] -> [(Point3d, String)] parse [] = [] parse xs = map pair (cleanList xs) where pair [ip, t, c] = (Point3d (ipToNum ip) (read t) 0.0, c) cleanList = filter (\x -> length x == 3)
-
查找列表中最常出现的项:
maxFreq :: [String] -> String maxFreq xs = fst $ foldl myCompare ("", 0) freqs where freqs = M.toList $ M.fromListWith (+) [(c, 1) | c <- xs] myCompare (oldS, oldV) (s,v) = if v > oldV then (s, v) else (oldS, oldV)
-
给定 KdTree、要使用的最近邻居数量以及训练集点,分类一个测试点:
test :: KdTree Point3d -> Int -> [(Point3d, String)] -> Point3d -> String test kdtree k pairList p = maxFreq $ map classify neighbors where neighbors = kNearestNeighbors kdtree k p classify x = fromJust (lookup x pairList)
-
定义
main
以读取 CSV 文件并处理数据:main = do rawCSV <- parseCSVFromFile "input.csv" either handleError doWork rawCSV
-
如果 CSV 无法正确读取,处理错误:
handleError = error "Invalid CSV file"
-
否则,从 CSV 数据创建一个 KdTree,并测试几个示例:
doWork rawCSV = do let ps = parse rawCSV let kdtree = fromList (map fst ps) let examples = [ ["71.190.100.100", "2000", "?"] , ["216.239.33.1", "1", "?"] ] let examplePts = map fst $ parse examples print $ map (test kdtree 2 ps) examplePts
-
运行代码以查看示例点的分类结果:
$ runhaskell Main.hs ["Human", "Bot"]
它是如何工作的…
k-最近邻算法查看训练集中 k 个最接近的点,并返回这些 k 个点中最常见的分类。由于我们处理的是点,每个坐标应该是可排序的。幸运的是,IP 地址有一个微弱的层次结构,我们可以利用这一点。我们将 IP 地址转换为 32 位数字,以获得一个有用的顺序,将其视为空间中一个点的坐标。
使用 Graphics.EasyPlot 可视化点
有时,在进行聚类或分类之前,简单地可视化数据点是很方便的,以便检查数据。这个教程将把一个点的列表传递给绘图库,轻松查看数据的图示。
准备工作
从 cabal 安装 easyplot:
$ cabal install easyplot
创建一个包含二维点的 CSV 文件:
$ cat input.csv
1,2
2,3
3,1
4,5
5,3
6,1
如何操作…
在一个新的文件Main.hs
中,按照以下步骤操作:
-
导入所需的库以读取 CSV 数据,以及用于绘制点的库:
import Text.CSV import Graphics.EasyPlot
-
创建一个辅助函数,将字符串记录列表转换为双精度数列表。例如,我们想将
[ "1.0,2.0", "3.5,4.5" ]
转换为[ (1.0, 2.0), (3.5, 4.5) ]
:tupes :: [[String]] -> [(Double, Double)] tupes records = [ (read x, read y) | [x, y] <- records ]
-
在
main
中,解析 CSV 文件以供后续使用:main = do result <- parseCSVFromFile "input.csv"
-
如果 CSV 文件有效,使用
plot :: TerminalType -> a -> IO Bool
函数绘制点:case result of Left err -> putStrLn "Error reading CSV file" Right csv -> do plot X11 $ Data2D [Title "Plot"] [] (tupes csv) return ()
它是如何工作的…
plot
的第一个参数告诉 gnuplot 输出应该显示的位置。例如,我们使用 X11 输出到 Linux 上的 X Window 系统。根据计算机的不同,我们可以在不同的终端类型之间选择。TerminalType
的构造函数如下:
-
Aqua
: 输出到 Mac OS X(Aqua 终端) -
Windows
: 输出为 MS Windows 格式 -
X11
: 输出到 X Window 系统 -
PS 文件路径
: 输出为 PostScript 文件 -
EPS 文件路径
: 输出为 EPS 文件路径 -
PNG 文件路径
: 输出为便携网络图形(PNG)并保存为文件 -
PDF 文件路径
: 输出为便携文档格式(PDF)并保存为文件 -
SVG 文件路径
: 输出为可缩放矢量图并保存为文件 -
GIF 文件路径
: 输出为图形交换格式(GIF)并保存为文件 -
JPEG 文件路径
: 输出为 JPEG 文件 -
Latex 文件路径
: 输出为 LaTeX 格式
plot 的第二个参数是图形,可以是 Graph2D
、Graph3D
,或者它们的列表。
第九章。并行与并发设计
本章将涵盖以下食谱:
-
使用 Haskell 运行时系统(RTS)选项
-
并行评估程序
-
按顺序控制并行算法
-
为并发操作分叉 I/O 动作
-
与分叉的 I/O 操作通信
-
终止分叉线程
-
使用 Par 单子并行化纯函数
-
并行映射列表
-
并行访问元组元素
-
实现 MapReduce 以计算单词频率
-
使用 Repa 并行处理图像
-
Haskell 中的运行时性能基准测试
-
使用 criterion 包来测量性能
-
在终端中进行运行时性能基准测试
介绍
数据分析研究中的一项伟大成就是对并行和并发设计的智能应用。随着我们收集越来越多的数据,我们能够发现越来越多的模式。然而,这也以时间和空间为代价。更多的数据可能需要更多的计算时间或更多的内存空间。这是一个非常现实的问题,本章将尝试解决它。
前几个食谱将介绍如何并行和顺序地调用纯程序。接下来的分叉食谱将涉及使用 I/O 动作的并发。然后,我们将深入学习如何并行访问列表和元组元素。接着,我们将在 Haskell 中实现 MapReduce 来高效解决一个费时的问题。
我们将通过学习如何进行运行时性能基准测试,结束对并行和并发设计的回顾。有时候,发现代码是否成功地并行运行的最简单方法是将其与非并行版本的代码进行时间对比。如果两者的计算时间看起来相同,那么很可能出现了问题。要么代码没有并行运行,要么激活并行的成本超过了其带来的好处。
使用 Haskell 运行时系统选项
Haskell 中的运行时系统(RTS)配置了如调度、分析和管理已编译 Haskell 程序存储等特殊选项。为了编写多线程代码,我们必须按照本食谱中概述的方式指定自己的 RTS 选项。
如需进一步阅读,GHC 官方 Haskell Wiki 网页上的 GHC 评论部分提供了关于运行时系统的详细说明,网址为ghc.haskell.org/trac/ghc/wiki/Commentary/Rts
。
如何操作……
打开终端,编译代码,并使用 RTS 选项运行它。假设我们的文件名为Main.hs
,然后执行以下命令:
$ ghc -O2 --make Main.hs -threaded -rtsopts
$ ./Main +RTS -N2
它是如何工作的……
为了利用多个线程,我们必须在编译代码时启用threaded
和rtsopts
标志。
现在,它已经用 rtsopts
编译,我们可以使用特殊指令在 +RTS
和 -RTS
标志之间运行程序。如果存在 +RTS
标志但没有 –RTS
标志,则假设 RTS 选项会持续到行尾。
我们通过在 RTS 参数中放入 -Nx
来设置使用的线程数,其中 x
表示“使用 x 个线程”。因此,要使用两个线程,我们应输入 -N2。要使用所有可能的线程,我们只需输入 -N。
还有更多……
指定 RTS 选项的另一种方法是在编译时使用 --with-rtsopts
标志。更高级的方法包括修改环境变量或覆盖运行时系统钩子。有关更多信息,请参见官方 Haskell 用户指南,网址为 www.haskell.org/ghc/docs/7.4.1/html/users_guide/runtime-control.html
。
并行评估一个过程
在这个配方中,我们将并行执行两个耗时的任务。我们将使用来自 hackage 的 parallel
包中的 rpar
函数。rpar
函数将其参数标注为并行执行。然后,我们调用 runEval
来实际执行计算。
准备工作
使用 cabal 安装 parallel
包,如下所示:
$ cabal install parallel
如何实现……
-
按如下方式导入 parallel 包:
import Control.Parallel.Strategies (runEval, rpar)
-
如下代码片段所示,评估两个任务并行执行,并在两个任务都完成后再返回结果:
main = do print $ runEval $ do a <- rpar task1 b <- rpar task2 return (a, b)
-
可以按如下方式创建一个耗时任务:
task1 = 8⁸⁹ :: Integer
-
另一个耗时任务可以按如下方式创建:
task2 = 8⁸⁸ :: Integer
-
按如下方式编译代码,启用
threaded
和rtsopts
标志:$ ghc -O2 --make Main.hs -threaded –rtsopts
-
通过指定核心数来运行它:
$ ./Main +RTS -N2
小贴士
本配方中的耗时计算(task1
和 task2
)需要大量内存,可能会超出正在使用的机器的限制。可以调整任务以使其更易于管理,例如 4⁸⁹ 或 4⁸⁸。特别是,本配方中,将这些简单的数学计算并行化所带来的开销可能大于其带来的收益。
它是如何工作的……
耗时函数通过 rpar
标注,表示计算应当并行进行。一旦应用了 runEval
,所触发的代码便会并行运行。代码的其他部分可以继续执行,直到这些并行运行的线程的输出被需要。
在我们的配方中,我们并行执行 task1
和 task2
。我们立即返回结果,以便在代码的未来部分使用,并且代码只在必要时等待任务完成。计算在后台进行,直到稍后需要时才会被处理。
另请参见
要探索在并行设计中使用序列的示例,请参阅 在序列中控制并行算法 配方。
在序列中控制并行算法
在本食谱中,我们将并行执行两个耗时的任务。我们将使用parallel
包中提供的rpar
函数和rseq
函数。rpar
函数将其参数标记为并行计算。另一个函数rseq
则强制按弱头正常形式进行顺序计算。
准备就绪
使用以下命令通过 cabal 安装parallel
包:
$ cabal install parallel
如何操作…
-
按照以下方式导入并使用并行包:
import Control.Parallel import Control.Parallel.Strategies Evaluate two tasks in parallel, and wait for both tasks to finish before returning. main = do print $ runEval $ do a <- rpar task1 b <- rpar task2 rseq a rseq b return (a, b)
-
按照以下方式执行一个耗时任务:
task1 = 8⁸⁹ :: Integer
-
按照以下方式执行另一个耗时任务:
task2 = 8⁸⁸ :: Integer
-
使用以下命令编译代码,并启用
threaded
和rtsopts
标志:$ ghc -O2 --make Main.hs -threaded –rtsopts
-
按照以下方式通过指定核心数来运行它:
$ ./Main +RTS -N2
它是如何工作的…
耗时函数通过rpar
或rseq
进行标注,分别描述计算是否应并行执行或按顺序执行。如果一个函数被启动为并行执行,那么在该值被需要之前,后续代码部分可以继续执行。在这种情况下,代码会被阻塞,直到并行操作完成。如果需要按顺序执行函数,代码会等待该函数计算出结果,只有在此之后才会继续执行。
在我们的食谱中,我们并行运行task1
和task2
。然后,我们对这些值运行rseq
,以演示顺序的概念。第一次调用rseq
时,我们强制代码等待task1
(由变量a
表示)完成。根据算法的并行设计,可能根本不需要强制顺序计算。我们还强制task2
(由变量b
表示)等待计算完成,以此来展示顺序是如何工作的。
另请参见
若要查看仅并行设计而没有顺序控制的示例,请参见并行评估过程食谱。
为了并发执行,进行 I/O 操作的分叉
一种快速且简单的方法是通过调用Control.Concurrent
包中的forkIO
函数将 I/O 类型函数在后台启动。在本食谱中,我们将展示在 Haskell 中如何并发地处理简单的输入/输出。我们将从用户输入中获取等待的秒数,然后在后台使其休眠,等线程醒来后打印一条消息。
如何操作…
-
按如下方式导入内建的并发包:
import Control.Concurrent (forkIO, threadDelay)
-
提示用户程序需要休眠的秒数。然后,通过调用我们在以下代码片段中定义的
sleep
函数,使程序睡眠相应的秒数。最后,递归地再次调用main
,以演示用户可以在后台线程运行的同时继续输入:main = do putStr "Enter number of seconds to sleep: " time <- fmap (read :: String -> Int) getLine forkIO $ sleep time main
-
定义一个函数,该函数接受要睡眠的秒数,并按如下方式将
threadDelay :: Int -> IO ()
应用于该值:sleep :: Int -> IO () sleep t = do let micro = t * 1000000 threadDelay micro putStrLn $ "[Just woke up after " ++ show t ++ " seconds]"
-
当我们运行程序时,可以快速输入多个数字,直到收到输出,示例如下:
$ ghci Main.hs Prelude> main Prelude> Enter number of seconds to sleep: 3 Prelude> Enter number of seconds to sleep: 2 Prelude> Enter number of seconds to sleep: [Just woke up after 2 seconds] [Just woke up after 3 seconds]
注意
print
和putrStrLn
函数不是原子的,因此您可能会看到输出交错的情况。
另请参见
要向派生的操作发送数据,请参考与派生的 I/O 操作进行通信的配方。
与派生的 I/O 操作进行通信
启动一个 I/O 类型的后台函数的快速简便方法是调用Control.Concurrent
包提供的forkIO
函数。在本配方中,我们将通过使用一种称为MVar
的变量类型发送消息来与派生的 I/O 操作进行通信。
准备就绪
按如下方式从 cabal 安装HTTP
包:
$ cabal install HTTP
如何操作……
-
按如下方式导入相关包:
import Network.HTTP import Control.Concurrent
-
创建一个新变量,将被派生进程使用。
newEmptyMVar
函数的类型为IO (MVar a)
,所以我们将表达式提取并标记为m
,如下所示:main = do m <- newEmptyMVar forkIO $ process m
-
在运行派生进程后,通过调用
putMVar :: MVar a -> a -> IO ()
向其发送数据,如下代码所示。该变量将保存给定的值,派生的进程等待该数据并恢复:putStrLn "sending first website..." putMVar m "http://www.haskell.com"
-
我们可以重用表达式并发送更多数据,如下所示:
putStrLn "sending second website..." putMVar m "http://www.gnu.org"
-
为了确保
main
在派生进程完成之前不会终止,我们通过调用threadDelay
函数强制main
等待 10 秒。这仅用于演示目的,完整的解决方案应该在派生完成后立即终止main
,如下代码片段所示:threadDelay $ 10 * 1000000
-
按如下方式定义将并行运行的派生代码:
process m = do putStrLn "waiting..." v <- takeMVar m resp <- get v putStrLn $ "response from " ++ show v ++ " is " ++ resp process m
-
创建一个函数以对 URL 执行 HTTP GET 请求,如下所示:
get :: String -> IO String get url = do resp <- simpleHTTP (getRequest url) body <- getResponseBody resp return $ take 10 body
-
程序的输出将如下所示:
$ runhaskell Main.hs sending first website... sending second website... waiting... waiting... response from "http://www.haskell.com" is <!doctype html> <html class="no-js" lang="en"> <head id="ctl00_Head1"><meta http-equiv="X-UA-C response from "http://www.gnu.org" is <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1 waiting...
另见
要查看使用forkIO
的更简单示例,请参考并发中的 I/O 操作派生的配方。
终止派生线程
当我们创建一个新线程时,可以跟踪其对应的线程 ID,以便稍后手动终止它。
在本配方中,我们将创建一个命令行界面,用于派生新进程下载一个巨大文件。通过d
命令后跟数字可以启动下载。运行d 1
将启动一个线程以下载项目#1。
我们将学习如何在线程仍然运行时终止它们。我们的终止线程命令将像k 1
,以便终止下载的项目#1。
如何操作……
在一个新文件中,我们称之为Main.hs
,插入以下代码:
-
按如下方式导入所需的包:
import Control.Concurrent import qualified Data.Map as M
-
让
main
调用辅助的download
函数:main = download (M.empty :: M.Map Int [ThreadId])
-
定义一个函数以接受用户查询并做出适当响应,如下所示:
download m = do input <- (getLine >>= return . words) respond m input >>= download
-
响应下载请求:
respond m ["d", nstr] = do putStrLn "Starting download..." let n = read nstr :: Int threadId <- forkIO $ massiveDownload n return $ M.insertWith (++) n [threadId] m
-
响应终止请求:
respond m ["k", nstr] = do let n = read nstr :: Int case (M.lookup n m) of Just threads -> do putStrLn "Killing download(s)..." mapM_ killThread threads download $ M.delete n m Nothing -> do putStrLn "No such download" download m
-
响应无效请求:
respond m _ = do putStrLn "Type `d #` to start a download or `k #` to kill it." return m
-
假装下载一个巨大文件,如下所示:
massiveDownload n = do threadDelay $ 10 * 1000000 putStrLn $ "[Download " ++ (show n) ++" complete!]"
-
运行代码并按如下方式调用几个下载和终止命令:
$ runhaskell Main.hs d 1 Starting download... d 2 Starting download... d 3 Starting download... k 1 Killing download(s)... [Download 2 complete!] [Download 3 complete!]
它是如何工作的……
程序跟踪从下载编号到线程 ID 的映射。每当启动一个新的下载时,我们会将相应的线程 ID 插入映射中。要终止一个线程,我们调用killThread
对相应的线程 ID 进行操作。
使用 Par 单子并行化纯函数
使用Control.Monad.Par
包中的 Par monad 来加速使用并行线程的纯函数。信息流由称为IVar
的变量引导。我们可以并行put
值到IVar
或者从中get
值。
准备好
在 cabal 上安装 Par monad 如下:
$ cabal install monad-par
怎么做…
-
导入 Par monad 如下:
import Control.Monad.Par
-
并行运行计算,并执行一些有趣的功能,如计算数字的数量并打印输出。
main = print $ length $ show $ runPar mypar
-
定义 I/O 类型动作如下:
mypar = do v1 <- new :: Par (IVar Integer) v2 <- new :: Par (IVar Integer) fork $ put v1 task1 fork $ put v2 task2 v1' <- get v1 v2' <- get v2 return (v1' + v2')
-
执行一个耗时任务,如下所示:
task1 = 8⁸⁸
-
执行另一个耗时任务如下:
task2 = 8⁸⁷
-
使用以下命令启用
threaded
和rtsopts
标志编译代码:$ ghc -O2 --make Main.hs -threaded –rtsopts
-
通过以下方式指定核心数来运行它:
$ ./Main +RTS -N2 15151337
还有更多…
代码的自然非并行化版本确实看起来更清洁。在以下示例中,我们看到与前一个示例中数学上的相同原理,但没有单子的使用。然而,我们不再具有并发的能力:
import Control.Monad.Par
main = print $ length $ show $ task1 + task2
task1 = 8⁸⁸
task2 = 8⁸⁸
另请参阅
处理使用 I/O 的计算,请参阅并发执行 I/O 操作配方。
在并行中映射列表
在此配方中,我们将并行应用 map 函数。给定一组值,我们将使用多个线程在每个值上应用函数。
怎么做…
-
导入并行策略如下:
import Control.Parallel.Strategies
-
使用以下代码片段使用
rdeepseq
策略映射列表:main = do let results = (parMap rdeepseq (¹⁰) [10¹⁰..10¹⁰+10000]) :: [Int] print results
-
编译并运行代码后,在此处显示打印输出的前几个字符:
-
使用以下命令启用
threaded
和rtsopts
标志编译代码:$ ghc -O2 --make Main.hs -threaded -rtsopts
-
通过以下方式指定核心数来运行代码:
$ ./Main +RTS -N2 [0,3644720378636855297,1420199564594381824,-9091195533231350103,-3969065814844243968,5699158338132413177,5185631055696798720,-1664423011715345679,-5301432476323807232,-6822228826283293807,-3978116359327587328,-2988467747382449959,669511447655481344,2530383018990005705,-7998143102955305984, ...
它的工作原理…
parMap
函数的类型为Strategy b -> (a -> b) -> [a] -> [b]
。它看起来与 map 函数的类型签名完全相同,只是接受称为 Strategy 的东西。Strategy将并行方法与代码实现分离。Strategy 的一个示例是rdeepseq
,它完全评估其参数。例如,Haskell 是惰性评估的,所以代码length [5⁵⁵, 6⁶⁶]
不会评估5⁵⁵
或6⁶⁶
的值。我们可以使用rdeepseq
示例更好地控制在并行运行时应评估哪些计算。
相比之下,代码的缓慢且简单版本如下所示:
main = do
print $ map (¹⁰) [10¹⁰..10¹⁰+10000]
尝试计时运行时间,以查看使用多线程时的显著差异。
还有更多…
根据应该调用并行性的方式有许多策略,如下所示:
-
r0
是最简单的策略,只是不评估表达式 -
dot
用于组合两个策略,以便在更复杂的表达式中进行更精细的控制 -
rseq
将立即评估表达式 -
rpar
将注释要并行评估的表达式
另请参阅
-
如果处理元组,请参阅并行访问元组元素配方
-
有关时间测试代码的更多细节,请参阅 Haskell 中的运行时性能基准测试 配方或 终端中的运行时性能基准测试 配方。
并行访问元组元素
在这个配方中,我们将介绍如何并行访问元组元素。
如何实现…
-
如下所示,导入内置包:
import Control.Parallel.Strategies
-
并行计算元组中的表达式。我们使用不同的策略执行此任务两次,展示如何轻松切换策略以改变代码的并行特性,如下所示:
main = do let (a, b) = withStrategy (parTuple2 rseq rseq) (task1, task2) print $ seq (a+b) "done 1" let (a, bs) = withStrategy (parTuple2 rseq rdeepseq) (task1, tasks) print $ seq (a + sum bs) "done 2"
-
如下所示,定义耗时任务:
task1 = 8⁸⁸ :: Integer task2 = 8⁸⁸ :: Integer tasks = [10¹⁰..10¹⁰+10000] :: [Integer]
-
如下所示,使用启用
threaded
和rtsopts
标志编译代码:$ ghc -O2 --make Main.hs -threaded -rtsopts
-
如下所示,通过指定核心数量来运行:
$ ./Main +RTS -N2
还有更多…
当处理超过两个元素的元组时,还有其他辅助方法,如 parTuple3
、parTuple4
、parTuple5
、parTuple6
、parTuple7
、parTuple8
和 parTuple9
。
另请参见
如果处理的是列表,请参阅 并行映射列表 配方。
实现 MapReduce 以计算单词频率
MapReduce 是一个高效并行算法框架,利用了分治法。如果一个任务可以被分解成更小的任务,而且每个单独任务的结果可以合并形成最终答案,那么 MapReduce 很可能是执行此任务的最佳框架。
在下图中,我们可以看到一个大的列表被拆分开来,映射函数在每个拆分部分上并行工作。所有映射完成后,框架的第二阶段启动,将各种计算归约为一个最终答案。
在这个配方中,我们将统计大量文本中的单词频率。给定多个单词文件,我们将应用 MapReduce 框架并行地找到单词频率。
准备工作
使用 cabal 安装 parallel
包,如下所示:
$ cabal install parallel
创建多个包含单词的文件。在这个配方中,我们下载一个巨大的文本文件并使用 UNIX split
命令将其拆分,如下所示:
$ wget norvig.com/big.txt
$ split –d big.txt words
$ ls words*
words00
words01
words02
words03
…
如何实现…
-
如下所示,导入相关包:
import Data.List (sort, group, sortBy, groupBy, isPrefixOf) import Control.Parallel import Control.Parallel.Strategies import Data.Char (isAlpha, isSpace, toLower) import Data.Map (Map, insertWith, empty, toList) import System.Directory import qualified Data.Map as M
-
定义 MapReduce 逻辑。映射函数应都出现在归约逻辑之前,如下所示:
mapReduce :: Strategy b -> (a -> b) -> Strategy b -> ([b] -> b) -> [a] -> b mapReduce mStrategy m rStrategy r input = mOutput `pseq` rOutput where mOutput = parMap mStrategy m input rOutput = r mOutput `using` rStrategy
-
如下所示,定义
mapper
函数以计算单词频率:mapper :: String -> [(String,Int)] mapper str = freqCount $ getWords str
-
如下所示,计算一个字符串中某个单词出现的次数:
freqCount :: [String] -> [(String, Int)] freqCount xs = map (\x -> (head x, length x)) . group . sort $ xs
-
如下所示,从任意字符语料库中提取单词:
getWords :: String -> [String] getWords str = words $ filter (\x -> isAlpha x || isSpace x) lower where lower = map toLower str
-
如下所示,将单词频率列表归约为一个最终答案:
reducer :: [[(String,Int)]] -> [(String,Int)] reducer ls = toList $ foldl (\m (k, v) -> insertWith (+) k v m) (empty :: Map String Int) (concat ls)
-
设置 MapReduce 问题并运行它:
main = do files <- getCurrentDirectory >>= getDirectoryContents let myFiles = filter ("words `isPrefixOf`) files rawFileData <- mapM readFile myFiles let freqMap = mapReduce (rpar `dot` rdeepseq) mapper rseq reducer fawFileData putStrLn $ "Found " ++ (show.length) freqMap ++ " words!" queryInput freqMap
-
请求输入,然后显示每个输入单词的频率:
queryInput freqMap = do putStrLn "Enter a sentence: " sentence <- readLine let freqs = map (`lookup` freqMap) (words sentence) print $ zip (words sentence) freqs queryInput freqMap
-
如下所示,使用启用
threaded
和rtsopts
标志编译代码:$ ghc -O2 --make Main.hs -threaded -rtsopts
-
如下所示,通过指定核心数量来运行:
$ ./Main +RTS -N2 Found 35537 words! Enter a sentence: no one who is young is ever going to be old [ ("no",Just 2328) , ("one",Just 3215) , ("who",Just 2994) , ("is",Just 9731) , ("young",Just 624) , ("is",Just 9731) , ("ever",Just 254) , ("going",Just 369) , ("to",Just 28614) , ("be",Just 6148) , ("old",Just 1138) ]
使用 Repa 并行操作图像
Repa 是一个强大的库,用于并行处理高维数组。我们将使用它来读取和编辑图像的像素。
准备工作
安装开发者图像库(DevIL),这是一个跨平台的图像处理工具包。可以从openil.sourceforge.net/download.php
下载,或者通过在 Debian 系统上使用apt-get
命令安装,具体如下:
$ sudo apt-get install libdevil-dev
从 cabal 安装 Repa 包,用于 DevIL 工具包,如下所示:
$ cabal install repa-devil
创建两个名为image1.png
和image2.png
的图像,它们具有相同的尺寸,如下所示:
这里是第二张图片:
如何操作…
-
导入以下库,如下所示:
import System.Environment (getArgs) import Data.Word (Word8) import qualified Data.Array.Repa as R import Data.Array.Repa hiding ((++)) import Data.Array.Repa.IO.DevIL (runIL, readImage, writeImage, IL, Image(RGB)) import Data.Array.Repa.Repr.ForeignPtr (F)
-
读取图像,处理它们,并生成输出图像,具体如下:
main = do let image1 = "image1.png" let image2 = "image2.png" runIL $ do (RGB a) <- readImage image1 (RGB b) <- readImage image2 imageOut <- (computeP $ intersect a b) :: IL (Array F DIM3 Word8) writeImage ("output.png") (RGB imageOut)
-
创建一个辅助函数以处理图像,代码如下:
intersect :: Array F DIM3 Word8 -> Array F DIM3 Word8 -> Array D DIM3 Word8 intersect a b = R.zipWith (\w1 w2 -> merge w1 w2) a b where merge w1 w2 = if w1 == w2 then 0 else 255
-
使用启用
threaded
和rtsopts
标志来编译代码,如下所示:$ ghc -O2 --make Main.hs -threaded -rtsopts
-
通过指定核心数来运行,具体如下:
$ ./Main +RTS -N2
输出结果如下:
工作原理…
图像被读取为三维的 Repa 像素数组,其中每个像素由一个 Word8 表示。前两个维度按宽度和高度索引图像,最后一个维度选择颜色通道(红色、绿色、蓝色或透明度)。
我们运行由 Repa 提供的zipWith
函数,将两张图像合并为一张,使用我们的交集/合并规则。为了实际高效地并行执行该过程,我们必须调用computeP
函数。
Haskell 中基准测试运行时性能
基准测试运行时是计时代码运行所需时间的过程。通过适当的基准测试,我们可以理解我们的并行或并发代码是否真的比朴素实现更快。本配方将展示如何在 Haskell 中计时代码运行时。
如何操作…
-
导入必要的库,如下所示:
import System.CPUTime (getCPUTime) import Control.Monad (replicateM_) import Control.Parallel.Strategies (NFData, rdeepseq) import Control.Exception (evaluate)
-
创建一个函数,用于打印纯任务的持续时间。评估纯表达式非常多次(10⁶),然后计算执行一个纯任务所需的平均 CPU 时间。
getCPUTime
函数返回自程序执行开始以来经过的皮秒数,如下代码片段所示:time :: (Num t, NFData t) => t -> IO () time y = do let trials = 10⁶ start <- getCPUTime replicateM_ trials $ do x <- evaluate $ 1 + y rdeepseq x `seq` return () end <- getCPUTime let diff = (fromIntegral (end - start)) / (10¹²) putStrLn $ "avg seconds: " ++ (show (diff / fromIntegral trials)) return ()
-
测试定时函数,如下所示:
main = do putStrLn "Starting pure..." time (3+7 :: Int) putStrLn "...Finished pure"
-
进行纯任务的测量值会被打印出来。实际测量值会根据机器的状态而有所不同。
Starting pure… Avg seconds: 3.2895e-7 …Finished pure
另见
终端中基准测试运行时性能,用于在 Haskell 环境之外生成基准测试结果的配方。
使用criterion
包来测量性能
为了获得更可靠的性能测量,criterion
包非常有用。该包的描述指出,使用简单的程序来计时纯代码存在一个重大缺陷。
"因为 GHC 在使用-O 进行编译时会进行激进优化,因此很容易编写看似无害的基准代码,这些代码只会被评估一次,导致除第一次外的所有定时循环都在计时‘无事可做’的成本。"
准备中
创建一个包含几个单词的small.txt
文件。创建一个文件big.txt
,并填充如下文本:
$ wget norvig.com/big.txt
按如下方式安装criterion
库:
$ cabal install criterion
如何做……
-
按如下方式导入包:
import Criterion.Main
-
定义我们希望计时的 I/O 函数如下:
splitUp filename = readFile filename >>= return . words
-
如下所示基准测试所需的功能:
main = defaultMain [ bgroup "splitUp" [ bench "big" $ nfIO $ splitUp "big.txt" , bench "small" $ nfIO $ splitUp "small.txt" ] ]
-
按如下方式运行代码:
$ ghc -O --make Main.hs $ ./Main warming up estimating clock resolution... mean is 1.082787 us (640001 iterations) found 42320 outliers among 639999 samples (6.6%) 1860 (0.3%) low severe 40460 (6.3%) high severe estimating cost of a clock call... mean is 33.40185 ns (10 iterations) found 2 outliers among 10 samples (20.0%) 1 (10.0%) high mild 1 (10.0%) high severe benchmarking splitUp/big collecting 100 samples, 1 iterations each, in estimated 65.46450 s mean: 656.1964 ms, lb 655.5417 ms, ub 657.1513 ms, ci 0.950 std dev: 4.018375 ms, lb 3.073741 ms, ub 5.746751 ms, ci 0.950 benchmarking splitUp/small mean: 15.33773 us, lb 15.16429 us, ub 15.56298 us, ci 0.950 std dev: 1.010893 us, lb 823.5281 ns, ub 1.277931 us, ci 0.950 found 8 outliers among 100 samples (8.0%) 5 (5.0%) high mild 3 (3.0%) high severe variance introduced by outliers: 61.572% variance is severely inflated by outliers
它是如何工作的……
通过在main
中调用该库的defaultMain
函数,我们可以利用一些非常强大的基准测试功能。例如,尝试运行以下命令,查看 criterion 支持的众多功能:
$ ./Main -h
在终端中基准测试运行时性能
基准测试运行时是计时代码运行所需时间的过程。这个技能非常宝贵,因为它有助于比较性能。通过在外部测量运行时,而不是在代码内部进行检测,我们可以轻松进行,不必理解代码的内部工作原理。如果我们使用的是类似 Unix 的系统,如 Linux 或 OS X,可以使用time
命令,而在 Windows 系统上,我们可以使用 PowerShell 中的Measure-Command
。
准备中
确保我们的机器是类 Unix 系统(如 Linux 或 OS X)或 Windows。如果不是,我们必须在线搜索计时执行的方法。
如何做……
-
在类 Unix 系统中,内置了
time
命令。运行任何代码时,我们可以在终端前加上time
,如下所示:$ time runhaskell Main.hs real 0m0.663s user 0m0.612s sys 0m0.057s
该命令的参数是 run,系统资源使用情况会立即总结。结果的实际准确性取决于机器。
-
在 Windows 上,我们可以使用 PowerShell 中的
Measure-Command
功能。打开 PowerShell,进入正确的目录,并执行以下命令:> Measure-Command { start-process runhaskell Main.hs –Wait }
-
你将看到以下格式的结果:
Days : 0 Hours : 0 Minutes : 0 Seconds : 1 Milliseconds : 10 Ticks : 10106611 TotalDays : 1.16974664351852E-05 TotalHours : 0.000280739194444444 TotalMinutes : 0.0168443516666667 TotalSeconds : 1.0106611 TotalMilliseconds : 1010.6611
另见
要在 Haskell 代码内部计时执行,请参考在 Haskell 中基准测试运行时性能的教程。
第十章:实时数据
本章将涵盖以下示例:
-
为实时情感分析流式传输 Twitter 数据
-
读取 IRC 聊天室消息
-
响应 IRC 消息
-
向 Web 服务器轮询以获取最新更新
-
检测实时文件目录变化
-
通过套接字进行实时通信
-
通过摄像头流检测面部和眼睛
-
用于模板匹配的摄像头流
介绍
首先收集数据然后再分析它是相当容易的。然而,有些任务可能需要将这两个步骤结合在一起。实时分析接收到的数据是本章的核心内容。我们将讨论如何管理来自 Twitter 推文、Internet Relay Chat(IRC)、Web 服务器、文件变更通知、套接字和网络摄像头的实时数据输入。
前三个示例将重点处理来自 Twitter 的实时数据。这些主题将包括用户发布的内容以及与关键词相关的帖子。
接下来,我们将使用两个独立的库与 IRC 服务器进行交互。第一个示例将展示如何加入一个 IRC 聊天室并开始监听消息,接下来的示例将展示我们如何在 IRC 服务器上监听直接消息。
如果不支持实时数据,常见的解决方法是频繁查询该数据。这一过程称为 轮询,我们将在某个示例中学习如何快速轮询 Web 服务器。
我们还将在文件目录中检测到文件被修改、删除或创建的变化。可以想象在 Haskell 中实现 Dropbox、OneDrive 或 Google Drive。
最后,我们将创建一个简单的服务器-客户端交互,使用套接字并操作实时网络摄像头流。
为实时情感分析流式传输 Twitter 数据
Twitter 上充满了每秒钟涌现的内容。开始调查实时数据的一个好方法是检查推文。
本示例将展示如何编写代码来响应与特定搜索查询相关的推文。我们使用外部 Web 端点来确定情感是积极、消极还是中立。
准备工作
安装 twitter-conduit
包:
$ cabal install twitter-conduit
为了解析 JSON,我们使用 yocto
:
$ cabal install yocto
如何操作…
按照以下步骤设置 Twitter 凭证并开始编码:
-
通过访问
apps.twitter.com
创建一个新的 Twitter 应用。 -
从此 Twitter 应用管理页面找到 OAuth 消费者密钥和 OAuth 消费者密钥。分别为
OAUTH_CONSUMER_KEY
和OAUTH_CONSUMER_SECRET
设置系统环境变量。大多数支持 sh 兼容 shell 的 Unix 系统支持export
命令:$ export OAUTH_CONSUMER_KEY="Your OAuth Consumer Key" $ export OAUTH_CONSUMER_SECRET="Your OAuth Consumer Secret"
-
此外,通过相同的 Twitter 应用管理页面找到 OAuth 访问令牌和 OAuth 访问密钥,并相应地设置环境变量:
$ export OAUTH_ACCESS_TOKEN="Your OAuth Access Token" $ export OAUTH_ACCESS_SECRET="Your OAuth Access Secret"
小贴士
我们将密钥、令牌和秘密 PIN 存储在环境变量中,而不是直接将它们硬编码到程序中,因为这些变量与密码一样重要。就像密码永远不应公开可见,我们尽力将这些令牌和密钥保持在源代码之外。
-
从
twitter-conduit
包的示例目录中下载Common.hs
文件,路径为github.com/himura/twitter-conduit/tree/master/sample
。研究userstream.hs
示例文件。 -
首先,我们导入所有相关的库:
{-# LANGUAGE OverloadedStrings #-} import qualified Data.Conduit as C import qualified Data.Conduit.List as CL import qualified Data.Text.IO as T import qualified Data.Text as T import Control.Monad.IO.Class (liftIO) import Network.HTTP (getResponseBody, getRequest, simpleHTTP, urlEncode) import Text.JSON.Yocto import Web.Twitter.Conduit (stream, statusesFilterByTrack) import Common import Control.Lens ((^!), (^.), act) import Data.Map ((!)) import Data.List (isInfixOf, or) import Web.Twitter.Types
-
在
main
中,运行我们的实时情感分析器以进行搜索查询:main :: IO () main = do let query = "haskell" T.putStrLn $ T.concat [ "Streaming Tweets that match \"" , query, "\"..."] analyze query
-
使用
Common
模块提供的runTwitterFromEnv'
函数,通过我们的 Twitter API 凭证连接到 Twitter 的实时流。我们将使用一些非常规的语法,如$$+-
或^!
。请不要被它们吓到,它们主要用于简洁表达。每当触发事件时,例如新的推文或新的关注,我们将调用我们的process
函数进行处理:analyze :: T.Text -> IO () analyze query = runTwitterFromEnv' $ do src <- stream $ statusesFilterByTrack query src C.$$+- CL.mapM_ (^! act (liftIO . process))
-
一旦我们获得事件触发的输入,就会运行
process
以获取输出,例如发现文本的情感。在本示例中,我们将情感输出附加到逗号分隔文件中:process :: StreamingAPI -> IO () process (SStatus s) = do let theUser = userScreenName $ statusUser s let theTweet = statusText s T.putStrLn $ T.concat [theUser, ": ", theTweet] val <- sentiment $ T.unpack theTweet let record = (T.unpack theUser) ++ "," ++ (show.fromRational) val ++ "\n" appendFile "output.csv" record print val
-
如果事件触发的输入不是推文,而是朋友关系事件或其他内容,则不执行任何操作:
process s = return ()
-
定义一个辅助函数,通过移除所有
@user
提及、#hashtags
或http://websites
来清理输入:clean :: String -> String clean str = unwords $ filter (\w -> not (or [ isInfixOf "@" w , isInfixOf "#" w , isInfixOf "http://" w ])) (words str)
-
使用外部 API 对文本内容进行情感分析。在本示例中,我们使用 Sentiment140 API,因为它简单易用。更多信息请参考
help.sentiment140.com/api
。为了防止被限制访问,也请提供appid
参数,并附上电子邮件地址或获取商业许可证:sentiment :: String -> IO Rational sentiment str = do let baseURL = "http://www.sentiment140.com/api/classify?text=" resp <- simpleHTTP $ getRequest $ baseURL ++ (urlEncode.clean) str body <- getResponseBody resp let p = polarity (decode body) / 4.0 return p
-
从我们的 API 的 JSON 响应中提取情感值:
polarity :: Value -> Rational polarity (Object m) = polarity' $ m ! "results" where polarity' (Object m) = fromNumber $ m ! "polarity" fromNumber (Number n) = n polarity _ = -1
-
运行代码,查看推文在全球任何人公开发布时即刻显示。情感值将是介于 0 和 1 之间的有理数,其中 0 表示负面情感,1 表示正面情感:
$ runhaskell Main.hs Streaming Tweets that match "x-men"…
查看以下输出:
我们还可以从output.csv
文件中批量分析数据。以下是情感分析的可视化表现:
它是如何工作的…
Twitter-conduit 包使用了来自原始包的 conduit 设计模式,原始包位于hackage.haskell.org/package/conduit
。conduit 文档中指出:
Conduit 是解决流数据问题的方案,允许在恒定内存中进行数据流的生产、转换和消费。它是惰性 I/O 的替代方案,保证了确定性的资源处理,并且与枚举器/迭代器和管道处于相同的通用解决方案空间中。
为了与 Twitter 的应用程序编程接口(API)进行交互,必须获得访问令牌和应用程序密钥。我们将这些值存储在环境变量中,并让 Haskell 代码从中检索。
Common.hs
文件负责处理单调的认证代码,应该保持不变。
反应每个 Twitter 事件的函数是process
。我们可以修改process
以满足我们特定的需求。更具体地说,我们可以修改情感分析函数,以使用不同的sentiment
分析服务。
还有更多内容…
我们的代码监听任何与我们查询匹配的推文。这个 Twitter-conduit 库还支持另外两种实时流:statusesFilterByFollow
和userstream
。前者获取指定用户列表的所有推文,后者获取该账户关注的用户的所有推文。
例如,通过将statusesFilterByTrack
查询替换为一些 Twitter 用户的 UID 来修改我们的代码:
analyze:: IO ()
analyze = runTwitterFromEnv' $ do
src <- statusesFilterByFollow [ 103285804, 450331119
, 64895420]
src C.$$+- CL.mapM_ (^! act (liftIO . process))
此外,为了仅获取我们关注的用户的推文,我们可以通过将statusesFilterByTrack
查询替换为userstream
来修改我们的代码:
analyze :: IO ()
analyze = runTwitterFromEnv' $ do
src <- stream userstream
src C.$$+- CL.mapM_ (^! act (liftIO . process))
通过github.com/himura/twitter-conduit/tree/master/sample
可以找到更多示例。
阅读 IRC 聊天室消息
Internet Relay Chat(IRC)是最古老且最活跃的群聊服务之一。Haskell 社区在 Freenode IRC 服务器(irc.freenode.org
)的#haskell
频道中拥有非常友好的存在。
在这个配方中,我们将构建一个 IRC 机器人,加入一个聊天室并监听文本对话。我们的程序将模拟一个 IRC 客户端,并连接到现有的 IRC 服务器之一。这个配方完全不需要外部库。
做好准备
确保启用互联网连接。
要测试 IRC 机器人,最好安装一个 IRC 客户端。例如,顶级的 IRC 客户端之一是Hexchat,可以从hexchat.github.io
下载。对于基于终端的 IRC 客户端,Irssi是最受欢迎的:www.irssi.org
。
在 Haskell wiki 上查看自己动手制作 IRC 机器人文章:www.haskell.org/haskellwiki/Roll_your_own_IRC_bot
。这个配方的代码大多基于 wiki 上的内容。
如何做…
在一个名为Main.hs
的新文件中,插入以下代码:
-
导入相关的包:
import Network import Control.Monad (forever) import System.IO import Text.Printf
-
指定 IRC 服务器的具体信息:
server = "irc.freenode.org" port = 6667 chan = "#haskelldata" nick = "awesome-bot"
-
连接到服务器并监听聊天室中的所有文本:
main = do h <- connectTo server (PortNumber (fromIntegral port)) hSetBuffering h NoBuffering write h "NICK" nick write h "USER" (nick++" 0 * :tutorial bot") write h "JOIN" chan listen h write :: Handle -> String -> String -> IO () write h s t = do hPrintf h "%s %s\r\n" s t printf "> %s %s\n" s t
-
定义我们的监听器。对于这个配方,我们将仅将所有事件回显到控制台:
listen :: Handle -> IO () listen h = forever $ do s <- hGetLine h putStrLn s
另见
要了解另一种与 IRC 交互的方式,请查看下一个配方,回应 IRC 消息。
回应 IRC 消息
另一种与 IRC 交互的方式是使用Network.SimpleIRC
包。此包封装了许多底层网络操作,并提供了有用的 IRC 接口。
在本教程中,我们将回应频道中的消息。如果有用户输入触发词,在本案例中为“host?”,我们将回复该用户其主机地址。
准备工作
安装Network.SimpleIRC
包:
$ cabal install simpleirc
要测试 IRC 机器人,安装 IRC 客户端会很有帮助。一个不错的 IRC 客户端是 Hexchat,可以从hexchat.github.io
下载。对于基于终端的 IRC 客户端,Irssi 是最好的之一:www.irssi.org
。
如何操作…
创建一个新的文件,我们称之为Main.hs
,并执行以下操作:
-
导入相关的库:
{-# LANGUAGE OverloadedStrings #-} import Network.SimpleIRC import Data.Maybe import qualified Data.ByteString.Char8 as B
-
创建接收到消息时的事件处理程序。如果消息是“host?”,则回复用户其主机信息:
onMessage :: EventFunc onMessage s m = do case msg of "host?" -> sendMsg s chan $ botMsg otherwise -> return () where chan = fromJust $ mChan m msg = mMsg m host = case mHost m of Just h -> h Nothing -> "unknown" nick = case mNick m of Just n -> n Nothing -> "unknown user" botMsg = B.concat [ "Hi ", nick, " , your host is ", host]
-
定义要监听的事件:
events = [(Privmsg onMessage)]
-
设置 IRC 服务器配置。连接到任意一组频道,并绑定我们的事件:
freenode = (mkDefaultConfig "irc.freenode.net" "awesome-bot") { cChannels = ["#haskelldata"] , cEvents = events }
-
连接到服务器。不要在新线程中运行,而是打印调试信息,按照相应的布尔参数来指定:
main = connect freenode False True
-
运行代码,打开 IRC 客户端进行测试:
另见
若要在不使用外部库的情况下连接 IRC 服务器,请参见之前的教程,读取 IRC 聊天室消息。
轮询 web 服务器以获取最新更新
一些网站的变化非常频繁。例如,Google 新闻和 Reddit 通常在我们刷新页面时,立刻加载最新的帖子。为了随时保持最新数据,最好是频繁地发送 HTTP 请求。
在本教程中,我们每 10 秒轮询一次新的 Reddit 帖子,如下图所示:
如何操作…
在一个名为Main.hs
的新文件中,执行以下步骤:
-
导入相关的库:
import Network.HTTP import Control.Concurrent (threadDelay) import qualified Data.Text as T
-
定义要轮询的 URL:
url = "http://www.reddit.com/r/pics/new.json"
-
定义一个函数来获取最新的 HTTP GET 请求数据:
latest :: IO String latest = simpleHTTP (getRequest url) >>= getResponseBody
-
轮询实际上是等待指定时间后递归地执行任务。在这种情况下,我们会等 10 秒钟再请求最新的网页数据:
poll :: IO a poll = do body <- latest print $ doWork body threadDelay (10 * 10⁶) poll
-
运行轮询:
main :: IO a main = do putStrLn $ "Polling " ++ url ++ " …" poll
-
每次 Web 请求后,分析数据。在本教程中,统计 Imgur 出现的次数:
doWork str = length $ T.breakOnAll (T.pack "imgur.com/") (T.pack str)
检测实时文件目录变化
在本教程中,我们将实时检测文件是否被创建、修改或删除。类似于流行的文件同步软件 Dropbox,我们每次遇到这样的事件时,都会执行一些有趣的操作。
准备工作
安装fsnotify
包:
$ cabal install fsnotify
如何操作…
在一个名为Main.hs
的新文件中,执行以下步骤:
-
导入相关的库:
{-# LANGUAGE OverloadedStrings #-} import Filesystem.Path.CurrentOS import System.FSNotify import Filesystem import Filesystem.Path (filename)
-
在当前目录上运行文件监视器:
main :: IO () main = do wd <- getWorkingDirectory print wd man <- startManager watchTree man wd (const True) doWork putStrLn "press return to stop" getLine putStrLn "watching stopped, press return to exit" stopManager man getLine return ()
-
处理每个文件变化事件。在本教程中,我们仅将操作输出到控制台:
doWork :: Event -> IO () doWork (Added filepath time) = putStrLn $ (show $ filename filepath) ++ " added" doWork (Modified filepath time) = putStrLn $ (show $ filename filepath) ++ " modified" doWork (Removed filepath time) = putStrLn $ (show $ filename filepath) ++ " removed"
-
运行代码并开始修改同一目录中的一些文件。例如,创建一个新文件,编辑它,然后删除它:
$ runhaskell Main.hs press return to stop FilePath "hello.txt" added FilePath "hello.txt" modified FilePath "hello.txt" removed
它是如何工作的…
fsnotify
库绑定到特定平台文件系统的事件通知服务。在基于 Unix 的系统中,这通常是inotify
(dell9.ma.utexas.edu/cgi-bin/man-cgi?inotify
)。
通过套接字实时通信
套接字提供了一种方便的实时程序间通信方式。可以把它们想象成一个聊天客户端。
在这个教程中,我们将从一个程序向另一个程序发送消息并获取响应。
如何做…
将以下代码插入到名为Main.hs
的新文件中:
-
创建服务器代码:
import Network ( listenOn, withSocketsDo, accept , PortID(..), Socket ) import System.Environment (getArgs) import System.IO ( hSetBuffering, hGetLine, hPutStrLn , BufferMode(..), Handle ) import Control.Concurrent (forkIO)
-
创建一个套接字连接以进行监听,并在其上附加我们的处理程序
sockHandler
:main :: IO () main = withSocketsDo $ do let port = PortNumber 9001 sock <- listenOn port putStrLn $ "Listening…" sockHandler sock
-
定义处理每个接收到的消息的处理程序:
sockHandler :: Socket -> IO () sockHandler sock = do (h, _, _) <- accept sock putStrLn "Connected!" hSetBuffering h LineBuffering forkIO $ process h forkIO $ respond h sockHandler sock
-
定义如何处理客户端发送的消息:
process :: Handle -> IO () process h = do line <- hGetLine h print line process h
-
通过用户输入向客户端发送消息:
respond h = withSocketsDo $ do txt <- getLine hPutStrLn h txt respond h
-
现在,在一个新文件
client.hs
中创建客户端代码。首先,导入库:import Network (connectTo, withSocketsDo, PortID(..)) import System.Environment (getArgs) import System.IO ( hSetBuffering, hPutStrLn , hGetLine, BufferMode(..) )
-
将客户端连接到相应的端口,并设置响应者和监听线程:
main = withSocketsDo $ do let port = PortNumber 9001 h <- connectTo "localhost" port putStrLn $ "Connected!" hSetBuffering h LineBuffering forkIO $ respond h forkIO $ process h loop
-
获取用户输入并将其作为消息发送:
respond h = do txt <- getLine hPutStrLn h txt respond h
-
监听来自服务器的传入消息:
process h = do line <- hGetLine h print line process h
-
先运行服务器,测试代码:
$ runhaskell Main.hs
-
接下来,在一个单独的终端中运行客户端:
$ runhaskell client.hs
-
现在,我们可以通过键入并按下Enter键在两者之间发送消息:
Hello? "yup, I can hear you!"
它是如何工作的…
hGetLine
函数会阻塞代码执行,这意味着代码执行在此处暂停,直到接收到消息为止。这允许我们等待消息并进行实时反应。
我们首先在计算机上指定一个端口,这只是一个尚未被其他程序占用的数字。服务器设置套接字,客户端连接到它,而无需进行设置。两者之间传递的消息是实时发生的。
以下图示演示了服务器-客户端模型的可视化:
通过摄像头流检测人脸和眼睛
摄像头是另一个实时数据的来源。随着帧的进出,我们可以使用 OpenCV 库进行强大的分析。
在这个教程中,我们通过实时摄像头流进行人脸检测。
准备工作
安装 OpenCV、SDL 和 FTGL 库以进行图像处理和计算机视觉:
sudo apt-get install libopencv-dev libsdl1.2-dev ftgl-dev
使用 cabal 安装 OpenCV 库:
cabal install cv-combinators
如何做…
创建一个新的源文件Main.hs
,并按照以下步骤操作:
-
导入相关库:
import AI.CV.ImageProcessors import qualified AI.CV.OpenCV.CV as CV import qualified Control.Processor as Processor import Control.Processor ((--<)) import AI.CV.OpenCV.Types (PImage) import AI.CV.OpenCV.CxCore (CvRect(..), CvSize(..)) import Prelude hiding (id) import Control.Arrow ((&&&), (***)) import Control.Category ((>>>), id)
-
定义摄像头流的来源。我们将使用内置的摄像头。若要改用视频,可以将
camera 0
替换为videoFile "./myVideo.mpeg"
:captureDev :: ImageSource captureDev = camera 0
-
缩小流的大小以提高性能:
resizer :: ImageProcessor resizer = resize 320 240 CV.CV_INTER_LINEAR
-
使用 OpenCV 提供的训练数据集检测图像中的人脸:
faceDetect :: Processor.IOProcessor PImage [CvRect] faceDetect = haarDetect "/usr/share/opencv/haarcascades/haarcascade_frontalface_alt.xml" 1.1 3 CV.cvHaarFlagNone (CvSize 20 20)
-
使用 OpenCV 提供的训练数据集检测图像中的眼睛:
eyeDetect :: Processor.IOProcessor PImage [CvRect] eyeDetect = haarDetect "/usr/share/opencv/haarcascades/haarcascade_eye.xml" 1.1 3 CV.cvHaarFlagNone (CvSize 20 20)
-
在人脸和眼睛周围画矩形框:
faceRects = (id &&& faceDetect) >>> drawRects eyeRects = (id &&& eyeDetect) >>> drawRects
-
捕获摄像头流,检测面部和眼睛,绘制矩形,并在两个不同的窗口中显示它们:
start = captureDev >>> resizer --< (faceRects *** eyeRects) >>> (window 0 *** window 1)
-
执行实时摄像头流并在按下某个键后停止:
main :: IO () main = runTillKeyPressed start
-
运行代码并查看网络摄像头,以检测面部和眼睛,结果如以下命令后的截图所示:
$ runhaskell Main.hs
它是如何工作的…
为了检测面部、眼睛或其他物体,我们使用haarDetect
函数,它执行从许多正面和负面测试案例中训练出来的分类器。这些测试案例由 OpenCV 提供,通常位于 Unix 系统中的/usr/share/opencv/haarcascades/
目录下。
cv-combinator 库提供了 OpenCV 底层操作的便捷抽象。为了运行任何有用的代码,我们必须定义一个源、一个过程和一个最终目标(也称为sink)。在我们的案例中,源是机器内置的摄像头。我们首先将图像调整为更易处理的大小(resizer
),然后将流分成两个并行流(--<
),在一个流中绘制面部框,在另一个流中绘制眼睛框,最后将这两个流输出到两个独立的窗口。有关 cv-combinators 包的更多文档,请参见hackage.haskell.org/package/cv-combinators
。
摄像头流的模板匹配
模板匹配是一种机器学习技术,用于寻找与给定模板图像匹配的图像区域。我们将把模板匹配应用于实时视频流的每一帧,以定位图像。
准备工作
安装 OpenCV 和 c2hs 工具包:
$ sudo apt-get install c2hs libopencv-dev
从 cabal 安装 CV 库。确保根据安装的 OpenCV 版本包含–fopencv24
或–fopencv23
参数:
$ cabal install CV -fopencv24
同时,创建一个小的模板图像。在这个实例中,我们使用的是 Lena 的图像,这个图像通常用于许多图像处理实验。我们将此图像文件命名为lena.png
:
如何操作…
在一个新的文件Main.hs
中,从以下步骤开始:
-
导入相关库:
{-#LANGUAGE ScopedTypeVariables#-} module Main where import CV.Image (loadImage, rgbToGray, getSize) import CV.Video (captureFromCam, streamFromVideo) import Utils.Stream (runStream_, takeWhileS, sideEffect) import CV.HighGUI (showImage, waitKey) import CV.TemplateMatching ( simpleTemplateMatch , MatchType(..) ) import CV.ImageOp ((<#)) import CV.Drawing (circleOp, ShapeStyle(..))
-
加载模板图像并开始对摄像头流进行模板匹配:
main = do Just t <- loadImage "lena.jpg" Just c <- captureFromCam 0 runStream_ . sideEffect (process t) . takeWhileS (\_ -> True) $ streamFromVideo c
-
对摄像头流的每一帧执行操作。具体来说,使用模板匹配来定位模板并围绕其绘制一个圆圈:
process t img = do let gray = rgbToGray img let ((mx, my), _) = simpleTemplateMatch CCOEFF_NORMED gray t let circleSize = (fst (getSize t)) `div` 2 let circleCenter = (mx + circleSize, my + circleSize) showImage "test" (img <# circleOp (0,0,0) circleCenter circleSize (Stroked 3)) waitKey 100 return ()
-
使用以下命令运行代码并显示模板图像。会在找到的图像周围绘制一个黑色圆圈:
$ runhaskell Main.hs
还有更多内容……
更多 OpenCV 示例可以在github.com/aleator/CV/tree/master/examples
找到。
第十一章 数据可视化
在本章中,我们将介绍以下可视化技术:
-
使用 Google 的 Chart API 绘制折线图
-
使用 Google 的 Chart API 绘制饼图
-
使用 Google 的 Chart API 绘制条形图
-
使用 gnuplot 显示折线图
-
显示二维点的散点图
-
与三维空间中的点进行交互
-
可视化图形网络
-
自定义图形网络图的外观
-
使用 D3.js 在 JavaScript 中渲染条形图
-
使用 D3.js 在 JavaScript 中渲染散点图
-
从向量列表中绘制路径图
引言
可视化在数据分析的所有步骤中都非常重要。无论我们是刚开始接触数据,还是已经完成了分析,通过图形辅助工具直观地理解数据总是非常有用的。幸运的是,Haskell 提供了许多库来帮助实现这一目标。
在本章中,我们将介绍使用各种 API 绘制折线图、饼图、条形图和散点图的技巧。除了常见的数据可视化,我们还将学习如何绘制网络图。此外,在最后一个技巧中,我们将通过在空白画布上绘制向量来描述导航方向。
使用 Google 的 Chart API 绘制折线图
我们将使用方便的 Google Chart API (developers.google.com/chart
) 来渲染折线图。该 API 会生成指向图表 PNG 图像的 URL。这个轻量级的 URL 比实际的图像更易于处理。
我们的数据将来自一个文本文件,其中包含按行分隔的数字列表。代码将生成一个 URL 来展示这些数据。
准备工作
按如下方式安装 GoogleChart
包:
$ cabal install hs-gchart
创建一个名为 input.txt
的文件,并按如下方式逐行插入数字:
$ cat input.txt
2
5
3
7
4
1
19
18
17
14
15
16
如何实现…
-
按如下方式导入 Google Chart API 库:
import Graphics.Google.Chart
-
从文本文件中获取输入,并将其解析为整数列表:
main = do rawInput <- readFile "input.txt" let nums = map (read :: String -> Int) (lines rawInput)
-
通过适当设置属性,创建一个图表 URL,如以下代码片段所示:
putStrLn $ chartURL $ setSize 500 200 $ setTitle "Example of Plotting a Chart in Haskell" $ setData (encodeDataSimple [nums]) $ setLegend ["Stock Price"] $ newLineChart
-
运行程序将输出一个 Google Chart URL,如下所示:
$ runhaskell Main.hs http://chart.apis.google.com/chart?chs=500x200&chtt=Example+of+Plotting+a+Chart+in+Haskell&chd=s:CFDHEBTSROPQ&chdl=Stock+Price&cht=lc
确保网络连接正常,并导航到该 URL 查看图表,如下图所示:
工作原理…
Google 会将所有图表数据编码到 URL 中。我们的图表越复杂,Google 图表 URL 就越长。在这个技巧中,我们使用 encodeDataSimple
函数,它创建了一个相对较短的 URL,但只接受 0 到 61 之间的整数(包括 0 和 61)。
还有更多内容…
为了可视化一个更详细的图表,允许数据具有小数位数,我们可以使用 encodeDataText :: RealFrac a => [[a]] -> ChartData
函数。这将允许 0 到 100 之间的十进制数(包含 0 和 100)。
为了在图表中表示更大的整数范围,我们应使用 encodeDataExtended
函数,它支持 0 到 4095 之间的整数(包括 0 和 4095)。
关于 Google Charts Haskell 包的更多信息,请访问 hackage.haskell.org/package/hs-gchart
。
另请参见
此配方需要连接互联网以查看图表。如果我们希望在本地执行所有操作,请参考 使用 gnuplot 显示折线图 配方。其他 Google API 配方包括 使用 Google 的 Chart API 绘制饼图 和 使用 Google 的 Chart API 绘制条形图。
使用 Google 的 Chart API 绘制饼图
Google Chart API 提供了一个外观非常优雅的饼图界面。通过正确地输入数据和标签,我们可以生成设计精良的饼图,如本配方所示。
准备工作
按如下方式安装 GoogleChart 包:
$ cabal install hs-gchart
创建一个名为 input.txt
的文件,每行插入数字,格式如下:
$ cat input.txt
2
5
3
7
4
1
19
18
17
14
15
16
如何操作…
-
按如下方式导入 Google Chart API 库:
import Graphics.Google.Chart
-
从文本文件中收集输入并将其解析为整数列表,如以下代码片段所示:
main = do rawInput <- readFile "input.txt" let nums = map (read :: String -> Int) (lines rawInput)
-
从以下代码中显示的饼图属性中打印出 Google Chart URL:
putStrLn $ chartURL $ setSize 500 400 $ setTitle "Example of Plotting a Pie Chart in Haskell" $ setData (encodeDataSimple [nums]) $ setLabels (lines rawInput) $ newPieChart Pie2D
-
运行程序将输出如下的 Google Chart URL:
$ runhaskell Main.hs http://chart.apis.google.com/chart?chs=500x400&chtt=Example+of+Plotting+a+Pie+Chart+in+Haskell&chd=s:CFDHEBTSROPQ&chl=2|5|3|7|4|1|19|18|17|14|15|16&cht=p
确保有网络连接,并访问该网址以查看下图所示的图表:
如何运作…
Google 将所有图表数据编码在 URL 中。图表越复杂,Google Chart URL 越长。在此配方中,我们使用 encodeDataSimple
函数,它创建一个相对较短的 URL,但仅接受 0 到 61(包括 0 和 61)之间的整数。饼图的图例由 setLabels :: [String] -> PieChart -> PieChart
函数按照与数据相同的顺序指定。
还有更多…
为了可视化一个包含小数的更详细的图表,我们可以使用 encodeDataText :: RealFrac a => [[a]] -> ChartData
函数。该函数支持 0 到 100(包括 0 和 100)之间的小数。
为了在图表中表示更大的整数范围,我们应使用 encodeDataExtended
函数,该函数支持 0 到 4095(包括 0 和 4095)之间的整数。
关于 Google Charts Haskell 包的更多信息,请访问 hackage.haskell.org/package/hs-gchart
。
另请参见
-
使用 Google 的 Chart API 绘制折线图
-
使用 Google 的 Chart API 绘制条形图
使用 Google 的 Chart API 绘制条形图
Google Chart API 也很好地支持条形图。在本配方中,我们将生成包含两组输入数据的条形图,以展示该 API 的实用性。
准备工作
按如下方式安装 GoogleChart
包:
$ cabal install hs-gchart
创建两个文件,名为 input1.txt
和 input2.txt
,每行插入数字,格式如下:
$ cat input1.txt
2
5
3
7
4
1
19
18
17
14
15
16
$ cat input2.txt
4
2
6
7
8
2
18
17
16
17
15
14
如何操作…
-
按如下方式导入 Google Chart API 库:
import Graphics.Google.Chart
-
从两个文本文件中获取两个输入值,并将它们解析为两个独立的整数列表,如以下代码片段所示:
main = do rawInput1 <- readFile "input1.txt" rawInput2 <- readFile "input2.txt" let nums1 = map (read :: String -> Int) (lines rawInput1) let nums2 = map (read :: String -> Int) (lines rawInput2)
-
同样设置柱状图并打印出 Google Chart URL,如下所示:
putStrLn $ chartURL $ setSize 500 400 $ setTitle "Example of Plotting a Bar Chart in Haskell" $ setDataColors ["00ff00", "ff0000"] $ setLegend ["A", "B"] $ setData (encodeDataSimple [nums1, nums2]) $ newBarChart Horizontal Grouped
-
运行程序将输出一个 Google Chart URL,如下所示:
$ runhaskell Main.hs http://chart.apis.google.com/chart?chs=500x400&chtt=Example+of+Plotting+a+Bar+Chart+in+Haskell&chco=00ff00,ff0000&chdl=A|B&chd=s:CFDHEBTSROPQ,ECGHICSRQRPO&cht=bhg
确保存在互联网连接并导航到该 URL 以查看以下图表:
如何实现…
Google 将所有图表数据编码在 URL 中。图表越复杂,Google Chart URL 就越长。在本教程中,我们使用encodeDataSimple
函数,它创建了一个相对较短的 URL,但仅接受 0 到 61 之间的整数。
还有更多…
若要可视化更详细的图表并允许数据具有小数位,我们可以改用 encodeDataText :: RealFrac a => [[a]] -> ChartData
函数。该函数允许介于 0 和 100 之间的小数。
若要在图表中表示更大的整数范围,我们应使用 encodeDataExtended
函数,该函数支持介于 0 和 4095 之间的整数。
关于 Google Charts Haskell 包的更多信息,请访问 hackage.haskell.org/package/hs-gchart
。
另见
若要使用其他 Google Chart 工具,请参考使用 Google Chart API 绘制饼图和使用 Google Chart API 绘制折线图的教程。
使用 gnuplot 显示折线图
绘制图表通常不需要互联网连接。因此,在本教程中,我们将展示如何在本地绘制折线图。
准备工作
本教程使用的库通过 gnuplot 渲染图表。我们应首先安装 gnuplot。
在基于 Debian 的系统(如 Ubuntu)上,我们可以使用 apt-get
安装,如下所示:
$ sudo apt-get install gnuplot-x11
gnuplot 的官方下载地址是其官方网站 www.gnuplot.info
。
安装 gnuplot 后,使用 cabal 安装 EasyPlot
Haskell 库,如下所示:
$ cabal install easyplot
如何实现…
-
按照以下方式导入
EasyPlot
库:import Graphics.EasyPlot
-
定义一个数字列表进行绘图,如下所示:
main = do let values = [4,5,16,15,14,13,13,17]
-
如以下代码片段所示,在
X11
窗口上绘制图表。X11
X Window 系统终端被许多基于 Linux 的机器使用。如果在 Windows 上运行,我们应使用Windows
终端。在 Mac OS X 上,我们应将X11
替换为Aqua
:plot X11 $ Data2D [ Title "Line Graph" , Style Linespoints , Color Blue] [] (zip [1..] values)
运行代码将生成一个 plot1.dat
数据文件,并从选定的终端显示可视化图表,如下图所示:
如何实现…
EasyPlot
库将所有用户指定的代码转换为 gnuplot 可理解的语言,用于绘制数据图表。
另见
若要使用 Google Chart API 而不是 easy plot,请参考使用 Google Chart API 绘制折线图的教程。
显示二维点的散点图
本教程介绍了一种快速简单的方法,可以将 2D 点列表可视化为图像中的散点。
准备工作
本食谱中使用的库通过 gnuplot 来渲染图表。我们应先安装 gnuplot。
在基于 Debian 的系统(如 Ubuntu)上,我们可以使用 apt-get
安装,方法如下:
$ sudo apt-get install gnuplot-x11
下载 gnuplot 的官方网站是 www.gnuplot.info
。
在设置好 gnuplot 后,使用 cabal 安装 easyplot
Haskell 库,如下所示:
$ cabal install easyplot
同样,安装一个辅助的 CSV 包,如下所示:
$ cabal install csv
同样,创建两个逗号分隔的文件 input1.csv
和 input2.csv
,这两个文件表示两组独立的点,如下所示:
$ cat input1.csv
1,2
3,2
2,3
2,2
3,1
2,2
2,1
$ cat input2.csv
7,4
8,4
6,4
7,5
7,3
6,4
7,6
它是如何工作的…
-
导入相关的包,如下所示:
import Graphics.EasyPlot import Text.CSV
-
定义一个辅助函数将 CSV 记录转换为数字元组,如下所示:
convertRawCSV :: [[String]] -> [(Double, Double)] convertRawCSV csv = [ (read x, read y) | [x, y] <- csv ]
-
读取这两个 CSV 文件,如下所示:
main = do csv1Raw <- parseCSVFromFile "input1.csv" csv2Raw <- parseCSVFromFile "input2.csv" let csv1 = case csv1Raw of Left err -> [] Right csv -> convertRawCSV csv let csv2 = case csv2Raw of Left err -> [] Right csv -> convertRawCSV csv
-
在同一图表上,使用不同颜色将两个数据集并排绘制。对于许多基于 Linux 的机器,使用
X11
终端来支持 X Window 系统,如下代码所示。如果在 Windows 上运行,则使用Windows
终端。在 Mac OS X 上,应将X11
替换为Aqua
:plot X11 $ [ Data2D [Color Red] [] csv1 , Data2D [Color Blue] [] csv2 ]
-
运行程序以显示下方截图中所示的图表:
它是如何工作的…
EasyPlot
库将所有用户指定的代码转换为 gnuplot 可理解的语言来绘制数据。plot 函数的最后一个参数可以接受多个数据集的列表来绘制图形。
另见
要可视化 3D 点,请参阅 与三维空间中的点交互 这一食谱。
与三维空间中的点交互
在可视化 3D 空间中的点时,交互式地旋转、缩放和平移表示非常有用。本食谱演示了如何在 3D 中绘制数据并实时交互。
准备工作
本食谱中使用的库通过 gnuplot 来渲染图表。我们应先安装 gnuplot。
在基于 Debian 的系统(如 Ubuntu)上,我们可以使用 apt-get
安装,方法如下:
$ sudo apt-get install gnuplot-x11
下载 gnuplot 的官方网站是 www.gnuplot.info
。
在设置好 gnuplot 后,使用 Cabal 安装 easyplot
Haskell 库,如下所示:
$ cabal install easyplot
同样,安装一个辅助的 CSV 包,如下所示:
$ cabal install csv
同样,创建两个逗号分隔的文件 input1.csv
和 input2.csv
,这两个文件表示两组独立的点,如下所示:
$ cat input1.csv
1,1,1
1,2,1
0,1,1
1,1,0
2,1,0
2,1,1
1,0,1
$ cat input2.csv
4,3,2
3,3,2
3,2,3
4,4,3
5,4,2
4,2,3
3,4,3
它是如何工作的…
-
导入相关的包,如下所示:
import Graphics.EasyPlot import Text.CSV
-
定义一个辅助函数将 CSV 记录转换为数字元组,如下所示:
convertRawCSV :: [[String]] -> [(Double, Double, Double)] convertRawCSV csv = [ (read x, read y, read z) | [x, y, z] <- csv ]
-
读取这两个 CSV 文件,如下所示:
main = do csv1Raw <- parseCSVFromFile "input1.csv" csv2Raw <- parseCSVFromFile "input2.csv" let csv1 = case csv1Raw of Left err -> [] Right csv -> convertRawCSV csv let csv2 = case csv2Raw of Left err -> [] Right csv -> convertRawCSV csv
-
使用
plot'
函数绘制数据,该函数会保持 gnuplot 运行以启用Interactive
选项。对于许多基于 Linux 的机器,使用X11
终端来支持 X Window 系统,如下代码所示。如果在 Windows 上运行,则使用Windows
终端。在 Mac OS X 上,应将X11
替换为Aqua
:plot' [Interactive] X11 $ [ Data3D [Color Red] [] csv1 , Data3D [Color Blue] [] csv2]
它是如何工作的…
EasyPlot
库将所有用户指定的代码转换成 gnuplot 能理解的语言,以绘制数据图表。最后一个参数 plot
可以接受一个数据集列表进行绘图。通过使用 plot'
函数,我们可以让 gnuplot 持续运行,这样我们可以通过旋转、缩放和平移三维图像与图形进行交互。
另请参阅
要可视化二维点,请参考 显示二维点的散点图 示例。
可视化图形网络
边和节点的图形化网络可能很难调试或理解,因此可视化可以极大地帮助我们。在本教程中,我们将把一个图形数据结构转换成节点和边的图像。
准备工作
要使用 Graphviz 图形可视化库,我们首先需要在机器上安装它。Graphviz 的官方网站包含了下载和安装说明(www.graphviz.org
)。在基于 Debian 的操作系统上,可以通过以下方式使用 apt-get
安装 Graphviz:
$ sudo apt-get install graphviz-dev graphviz
接下来,我们需要通过 Cabal 安装 Graphviz 的 Haskell 绑定,具体方法如下:
$ cabal install graphviz
如何操作…
-
导入相关的库,如下所示:
import Data.Text.Lazy (Text, empty, unpack) import Data.Graph.Inductive (Gr, mkGraph) import Data.GraphViz (GraphvizParams, nonClusteredParams, graphToDot) import Data.GraphViz.Printing (toDot, renderDot)
-
使用以下代码行创建一个通过识别形成边的节点对来定义的图形:
myGraph :: Gr Text Text myGraph = mkGraph [ (1, empty) , (2, empty) , (3, empty) ] [ (1, 2, empty) , (1, 3, empty) ]
-
设置图形使用默认参数,如下所示:
myParams :: GraphvizParams n Text Text () Text myParams = nonClusteredParams
-
如下所示,将图形的 dot 表示打印到终端:
main :: IO () main = putStr $ unpack $ renderDot $ toDot $ graphToDot myParams myGraph
-
运行代码以获取图形的 dot 表示,并将其保存到一个单独的文件中,如下所示:
$ runhaskell Main.hs > graph.dot
-
对该文件运行 Graphviz 提供的
dot
命令,以渲染出如下的图像:$ dot -Tpng graph.dot > graph.png
-
现在我们可以查看生成的
graph.png
文件,截图如下所示:
如何工作…
graphToDot
函数将图形转换为 DOT 语言,以描述图形。这是图形的文本序列化形式,可以被 Graphviz 的 dot
命令读取并转换成可视化图像。
更多内容…
在本教程中,我们使用了 dot
命令。Graphviz 网站还描述了其他可以将 DOT 语言文本转换成可视化图像的命令:
dot - "层级"或分层绘制有向图。如果边具有方向性,这是默认的工具。
neato - "弹簧模型"布局。如果图形不太大(约 100 个节点)且你对图形没有其他了解,这是默认的工具。Neato 尝试最小化一个全局能量函数,这等同于统计多维尺度化。
fdp - "弹簧模型"布局,类似于 neato,但通过减少力来完成布局,而不是使用能量。
sfdp - fdp 的多尺度版本,用于大图的布局。
twopi - 径向布局,基于 Graham Wills 97。节点根据与给定根节点的距离,放置在同心圆上。
circo - 圆形布局,参考 Six 和 Tollis 99,Kauffman 和 Wiese 02。这适用于某些包含多个循环结构的图,如某些电信网络。
另见
要进一步更改图形的外观和感觉,请参考 自定义图形网络图的外观 这一食谱。
自定义图形网络图的外观
为了更好地呈现数据,我们将介绍如何定制图形网络图的设计。
准备工作
要使用 Graphviz 图形可视化库,我们首先需要在机器上安装它。Graphviz 的官方网站包含了下载和安装说明,网址为 www.graphviz.org
。在基于 Debian 的操作系统上,可以使用apt-get
命令来安装 Graphviz,方法如下:
$ sudo apt-get install graphviz-dev graphviz
接下来,我们需要从 Cabal 安装 Graphviz Haskell 绑定,方法如下:
$ cabal install graphviz
如何操作…
-
导入相关的函数和库,以自定义 Graphviz 图形,方法如下:
import Data.Text.Lazy (Text, pack, unpack) import Data.Graph.Inductive (Gr, mkGraph) import Data.GraphViz ( GraphvizParams(..), GlobalAttributes( GraphAttrs, NodeAttrs, EdgeAttrs ), X11Color(Blue, Orange, White), nonClusteredParams, globalAttributes, fmtNode, fmtEdge, graphToDot ) import Data.GraphViz.Printing (toDot, renderDot) import Data.GraphViz.Attributes.Complete
-
按照以下代码片段,首先指定所有节点,然后指定哪些节点对形成边,来定义我们的自定义图形:
myGraph :: Gr Text Text myGraph = mkGraph [ (1, pack "Haskell") , (2, pack "Data Analysis") , (3, pack "Haskell Data Analysis") , (4, pack "Profit!")] [ (1, 3, pack "learn") , (2, 3, pack "learn") , (3, 4, pack "???")]
-
按照以下方式定义我们自己的自定义图形参数:
myParams :: GraphvizParams n Text Text () Text myParams = nonClusteredParams {
-
让图形引擎知道我们希望边缘是有向箭头,方法如下:
isDirected = True
-
设置图形、节点和边缘外观的全局属性如下:
, globalAttributes = [myGraphAttrs, myNodeAttrs, myEdgeAttrs]
-
按照我们自己的方式格式化节点如下:
, fmtNode = myFN
-
按照我们自己的方式格式化边缘如下:
, fmtEdge = myFE }
-
按照以下代码片段定义自定义内容:
where myGraphAttrs = GraphAttrs [ RankDir FromLeft , BgColor [toWColor Blue] ] myNodeAttrs = NodeAttrs [ Shape BoxShape , FillColor [toWColor Orange] , Style [SItem Filled []] ] myEdgeAttrs = EdgeAttrs [ Weight (Int 10) , Color [toWColor White] , FontColor (toColor White) ] myFN (n,l) = [(Label . StrLabel) l] myFE (f,t,l) = [(Label . StrLabel) l]
-
将图形的 DOT 语言表示打印到终端。
main :: IO () main = putStr $ unpack $ renderDot $ toDot $ graphToDot myParams myGraph
-
运行代码以获取图形的
dot
表示,可以将其保存在单独的文件中,方法如下:$ runhaskell Main.hs > graph.dot
-
在此文件上运行 Graphviz 提供的
dot
命令,以渲染图像,方法如下:$ dot -Tpng graph.dot > graph.png
我们现在可以查看生成的graph.png
文件,如下所示的截图:
工作原理…
graphToDot
函数将图形转换为 DOT 语言,以描述图形。这是一种图形的文本序列化格式,可以被 Graphviz 的 dot
命令读取,并转换为可视化图像。
还有更多……
图形、节点和边缘的所有可能自定义选项都可以在 Data.GraphViz.Attributes.Complete
包文档中找到,网址为 hackage.haskell.org/package/graphviz-2999.12.0.4/docs/Data-GraphViz-Attributes-Complete.html
。
使用 D3.js 在 JavaScript 中渲染条形图
我们将使用名为D3.js
的便携式 JavaScript 库来绘制条形图。这使得我们能够轻松地创建一个包含图表的网页,该图表来自 Haskell 代码。
准备工作
设置过程中需要连接互联网。
按照以下方式安装 d3js
Haskell 库:
$ cabal install d3js
创建一个网站模板,用于承载生成的 JavaScript 代码,方法如下:
$ cat index.html
JavaScript 代码如下:
<html>
<head>
<title>Chart</title>
</head>
<body>
<div id='myChart'></div>
<script charset='utf-8' src='http://d3js.org/d3.v3.min.js'></script>
<script charset='utf-8' src='generated.js'></script>
</body>
</html>
如何操作…
-
按照以下方式导入相关的包:
import qualified Data.Text as T import qualified Data.Text.IO as TIO import D3JS
-
使用
bars
函数创建条形图。输入指定的值和要绘制的条形数量,如以下代码片段所示:myChart nums numBars = do let dim = (300, 300) elem <- box (T.pack "#myChart") dim bars numBars 300 (Data1D nums) elem addFrame (300, 300) (250, 250) elem
-
定义要绘制的条形图的值和数量如下:
main = do let nums = [10, 40, 100, 50, 55, 156, 80, 74, 40, 10] let numBars = 5
-
使用
reify
函数从数据生成 JavaScriptD3.js
代码。将 JavaScript 写入名为generated.js
的文件,如下所示:let js = reify $ myChart nums numBars TIO.writeFile "generated.js" js
-
在
index.html
文件和generated.js
文件并排存在的情况下,我们可以使用支持 JavaScript 的浏览器打开index.html
网页,并看到如下所示的图表:
它是如何工作的…
D3.js
库是一个用于创建优雅可视化和图表的 JavaScript 库。我们使用浏览器运行 JavaScript 代码,它也充当我们的图表渲染引擎。
另请参阅
另一个D3.js
的用法,请参阅使用 D3.js 在 JavaScript 中渲染散点图食谱。
使用 D3.js 在 JavaScript 中渲染散点图
我们将使用名为D3.js
的便携式 JavaScript 库来绘制散点图。这样我们就可以轻松地创建一个包含图表的网页,该图表来自 Haskell 代码。
准备工作
进行此设置需要互联网连接。
如下所示安装d3js
Haskell 库:
$ cabal install d3js
创建一个网站模板来承载生成的 JavaScript 代码,如下所示:
$ cat index.html
JavaScript 代码如下所示:
<html>
<head>
<title>Chart</title>
</head>
<body>
<div id='myChart'></div>
<script charset='utf-8' src='http://d3js.org/d3.v3.min.js'></script>
<script charset='utf-8' src='generated.js'></script>
</body>
</html>
如何操作…
-
导入相关库,如下所示:
import D3JS import qualified Data.Text as T import qualified Data.Text.IO as TIO
-
定义散点图并输入点列表,如下所示:
myPlot points = do let dim = (300, 300) elem <- box (T.pack "#myChart") dim scatter (Data2D points) elem addFrame (300, 300) (250, 250) elem
-
定义要绘制的点列表,如下所示:
main = do let points = [(1,2), (5,10), (139,138), (140,150)]
-
使用
reify
函数从数据生成 JavaScriptD3.js
代码。将 JavaScript 写入名为generated.js
的文件,如下所示:let js = reify $ myPlot points TIO.writeFile "generated.js" js
-
在
index.html
和generated.js
文件并排存在的情况下,我们可以使用支持 JavaScript 的浏览器打开index.html
网页,并看到如下所示的图表:
它是如何工作的…
graphToDot
函数将图表转换为 DOT 语言来描述图表。这是图表的文本序列化格式,可以通过 Graphviz 的dot
命令读取并转换为可视化图像。
另请参阅
另一个D3.js
的用法,请参阅使用 D3.js 在 JavaScript 中渲染条形图食谱。
从向量列表绘制路径
在这个食谱中,我们将使用diagrams
包来从驾驶路线中绘制路径。我们将所有可能的旅行方向分类为八个基本方向,并附上相应的距离。我们使用下图中 Google Maps 提供的方向,并从文本文件中重建这些方向:
准备工作
如下所示安装diagrams
库:
$ cabal install diagrams
创建一个名为input.txt
的文本文件,其中包含八个基本方向之一,后面跟着距离,每一步用新的一行分隔:
$ cat input.txt
N 0.2
W 0.1
S 0.6
W 0.05
S 0.3
SW 0.1
SW 0.2
SW 0.3
S 0.3
如何操作…
-
导入相关库,如下所示:
{-# LANGUAGE NoMonomorphismRestriction #-} import Diagrams.Prelude import Diagrams.Backend.SVG.CmdLine (mainWith, B)
-
从一系列向量中绘制一个连接的路径,如下所示:
drawPath :: [(Double, Double)] -> Diagram B R2 drawPath vectors = fromOffsets . map r2 $ vectors
-
读取一系列方向,将其表示为向量列表,并按如下方式绘制路径:
main = do rawInput <- readFile "input.txt" let vs = [ makeVector dir (read dist) | [dir, dist] <- map words (lines rawInput)] print vs mainWith $ drawPath vs
-
定义一个辅助函数,根据方向及其对应的距离创建一个向量,如下所示:
makeVector :: String -> Double -> (Double, Double) makeVector "N" dist = (0, dist) makeVector "NE" dist = (dist / sqrt 2, dist / sqrt 2) makeVector "E" dist = (dist, 0) makeVector "SE" dist = (dist / sqrt 2, -dist / sqrt 2) makeVector "S" dist = (0, -dist) makeVector "SW" dist = (-dist / sqrt 2, -dist / sqrt 2) makeVector "W" dist = (-dist, 0) makeVector "NW" dist = (-dist / sqrt 2, dist / sqrt 2) makeVector _ _ = (0, 0)
-
编译代码并按如下方式运行:
$ ghc --make Main.hs $ ./Main –o output.svg –w 400
它是如何工作的…
mainWith
函数接收一个 Diagram
类型,并在终端中调用时生成相应的图像文件。我们通过 drawPath
函数获得 Diagram
,该函数通过偏移量将向量连接在一起。
第十二章 导出与展示
本章将涵盖如何导出结果并通过以下食谱优雅地展示它们:
-
将数据导出到 CSV 文件
-
将数据导出为 JSON
-
使用 SQLite 存储数据
-
将数据保存到 MongoDB 数据库
-
在 HTML 网页中展示结果
-
创建 LaTeX 表格以展示结果
-
使用文本模板个性化消息
-
将矩阵值导出到文件
介绍
在数据收集、清洗、表示和分析后,数据分析的最后一步是将数据导出并以可用格式展示。 本章中的食谱将展示如何将数据结构保存到磁盘,以供其他程序后续使用。此外,我们还将展示如何使用 Haskell 优雅地展示数据。
将数据导出到 CSV 文件
有时,使用像 LibreOffice、Microsoft Office Excel 或 Apple Numbers 这样的电子表格程序查看数据更为便捷。导出和导入简单电子表格表格的标准方式是通过逗号分隔值(CSV)。
在这个食谱中,我们将使用cassava
包轻松地从数据结构编码一个 CSV 文件。
准备工作
使用以下命令从 cabal 安装 Cassava CSV 包:
$ cabal install cassava
如何实现……
-
使用以下代码导入相关包:
import Data.Csv import qualified Data.ByteString.Lazy as BSL
-
定义将作为 CSV 导出的数据关联列表。在这个食谱中,我们将字母和数字配对,如以下代码所示:
myData :: [(Char, Int)] myData = zip ['A'..'Z'] [1..]
-
运行
encode
函数将数据结构转换为懒加载的 ByteString CSV 表示,如以下代码所示:main = BSL.writeFile "letters.csv" $ encode myData
它是如何工作的……
CSV 文件只是记录的列表。Cassava 库中的encode
函数接受实现了ToRecord
类型类的项列表。
在这个食谱中,我们可以看到像('A', 1)
这样的大小为 2 的元组是encode
函数的有效参数。默认情况下,支持大小为 2 到 7 的元组以及任意大小的列表。元组或列表的每个元素必须实现ToField
类型类,大多数内置的原始数据类型默认支持该类。有关该包的更多细节,请访问hackage.haskell.org/package/cassava
。
还有更多……
为了方便地将数据类型转换为 CSV,我们可以实现ToRecord
类型类。
例如,Cassava 文档展示了以下将Person
数据类型转换为 CSV 记录的例子:
data Person = Person { name :: Text, age :: Int }
instance ToRecord Person where
toRecord (Person name age) = record [
toField name, toField age]
另请参见
如果是 JSON 格式,请参考以下导出数据为 JSON食谱。
导出数据为 JSON
存储可能不遵循严格模式的数据的便捷方式是通过 JSON。为此,我们将使用一个名为Yocto的简便 JSON 库。它牺牲了性能以提高可读性并减小体积。
在这个食谱中,我们将导出一个点的列表为 JSON 格式。
准备工作
使用以下命令从 cabal 安装 Yocto JSON 编码器和解码器:
$ cabal install yocto
如何实现……
从创建一个新的文件开始,我们称其为Main.hs
,并执行以下步骤:
-
如下所示导入相关数据结构:
import Text.JSON.Yocto import qualified Data.Map as M
-
如下所示定义一个二维点的数据结构:
data Point = Point Rational Rational
-
将
Point
数据类型转换为 JSON 对象,如下方代码所示:pointObject (Point x y) = Object $ M.fromList [ ("x", Number x) , ("y", Number y)]
-
创建点并构建一个 JSON 数组:
main = do let points = [ Point 1 1 , Point 3 5 , Point (-3) 2] let pointsArray = Array $ map pointObject points
-
将 JSON 数组写入文件,如下方代码所示:
writeFile "points.json" $ encode pointsArray
-
运行代码时,我们会发现生成了
points.json
文件,如下方代码所示:$ runhaskell Main.hs $ cat points.json [{"x":1,"y":1}, {"x":3,"y":5}, {"x":-3,"y":2}]
还有更多内容…
若需更高效的 JSON 编码器,请参考 Aeson 包,位于hackage.haskell.org/package/aeson
。
参见
要将数据导出为 CSV,请参考前面标题为导出数据到 CSV 文件的食谱。
使用 SQLite 存储数据
SQLite 是最流行的数据库之一,用于紧凑地存储结构化数据。我们将使用 Haskell 的 SQL 绑定来存储字符串列表。
准备工作
我们必须首先在系统上安装 SQLite3 数据库。在基于 Debian 的系统上,我们可以通过以下命令进行安装:
$ sudo apt-get install sqlite3
使用以下命令从 cabal 安装 SQLite 包:
$ cabal install sqlite-simple
创建一个名为test.db
的初始数据库,并设置其模式。在本食谱中,我们只会存储整数和字符串,如下所示:
$ sqlite3 test.db "CREATE TABLE test (id INTEGER PRIMARY KEY, str text);"
如何实现…
-
导入相关库,如下方代码所示:
{-# LANGUAGE OverloadedStrings #-} import Control.Applicative import Database.SQLite.Simple import Database.SQLite.Simple.FromRow
-
为
TestField
(我们将要存储的数据类型)创建一个FromRow
类型类的实现,如下方代码所示:data TestField = TestField Int String deriving (Show) instance FromRow TestField where fromRow = TestField <$> field <*> field
-
创建一个辅助函数,用于仅为调试目的从数据库中检索所有数据,如下方代码所示:
getDB :: Connection -> IO [TestField] getDB conn = query_ conn "SELECT * from test"
-
创建一个辅助函数,将字符串插入数据库,如下方代码所示:
insertToDB :: Connection -> String -> IO () insertToDB conn item = execute conn "INSERT INTO test (str) VALUES (?)" (Only item)
-
如下所示连接到数据库:
main :: IO () main = withConnection "test.db" dbActions
-
设置我们希望插入的字符串数据,如下方代码所示:
dbActions :: Connection -> IO () dbActions conn = do let dataItems = ["A", "B", "C"]
-
将每个元素插入数据库,如下方代码所示:
mapM_ (insertToDB conn) dataItems
-
使用以下代码打印数据库内容:
r <- getDB conn mapM_ print r
-
我们可以通过调用以下命令验证数据库中是否包含新插入的数据:
$ sqlite3 test.db "SELECT * FROM test" 1|A 2|C 3|D
参见
若使用另一种类型的数据库,请参考以下食谱保存数据到 MongoDB 数据库。
保存数据到 MongoDB 数据库
MongoDB 可以非常自然地使用 JSON 语法存储非结构化数据。在本例中,我们将把一组人员数据存储到 MongoDB 中。
准备工作
我们必须首先在机器上安装 MongoDB。安装文件可以从www.mongodb.org
下载。
我们需要使用以下命令为数据库创建一个目录:
$ mkdir ~/db
最后,使用以下命令在该目录下启动 MongoDB 守护进程:
$ mongod –dbpath ~/db
使用以下命令从 cabal 安装 MongoDB 包:
$ cabal install mongoDB
如何实现…
创建一个名为Main.hs
的新文件,并执行以下步骤:
-
按照如下方式导入库:
{-# LANGUAGE OverloadedStrings, ExtendedDefaultRules #-} import Database.MongoDB import Control.Monad.Trans (liftIO)
-
如下所示定义一个表示人物姓名的数据类型:
data Person = Person { first :: String , last :: String }
-
设置我们希望存储的几个数据项,如下所示:
myData :: [Person] myData = [ Person "Mercury" "Merci" , Person "Sylvester" "Smith"]
-
连接到 MongoDB 实例并存储所有数据,如下所示:
main = do pipe <- runIOE $ connect (host "127.0.0.1") e <- access pipe master "test" (store myData) close pipe print e
-
按照以下方式将
Person
数据类型转换为适当的 MongoDB 类型:store vals = insertMany "people" mongoList where mongoList = map (\(Person f l) -> ["first" =: f, "last" =: l]) vals
-
我们必须确保 MongoDB 守护进程正在运行。如果没有,我们可以使用以下命令创建一个监听我们选择目录的进程:
$ mongod --dbpath ~/db
-
运行代码后,我们可以通过以下命令检查操作是否成功,方法是访问 MongoDB:
$ runhaskell Main.hs $ mongo > db.people.find() { "_id" : ObjectId("536d2b13f8712126e6000000"), "first" : "Mercury", "last" : "Merci" } { "_id" : ObjectId("536d2b13f8712126e6000001"), "first" : "Sylvester", "last" : "Smith" }
另见
对于 SQL 的使用,请参考之前的使用 SQLite 存储数据的做法。
在 HTML 网页中展示结果
在线共享数据是触及广泛受众的最快方式之一。然而,直接将数据输入到 HTML 中可能会耗费大量时间。本做法将使用 Blaze Haskell 库生成一个网页,来展示数据结果。更多文档和教程,请访问项目网页jaspervdj.be/blaze/
。
准备工作
从 cabal 使用以下命令安装 Blaze 包:
$ cabal install blaze-html
如何操作…
在一个名为Main.hs
的新文件中,执行以下步骤:
-
按照以下方式导入所有必要的库:
{-# LANGUAGE OverloadedStrings #-} import Control.Monad (forM_) import Text.Blaze.Html5 import qualified Text.Blaze.Html5 as H import Text.Blaze.Html.Renderer.Utf8 (renderHtml) import qualified Data.ByteString.Lazy as BSL
-
按照以下代码片段将字符串列表转换为 HTML 无序列表:
dataInList :: Html -> [String] -> Html dataInList label items = docTypeHtml $ do H.head $ do H.title "Generating HTML from data" body $ do p label ul $ mapM_ (li . toHtml) items
-
创建一个字符串列表,并按以下方式将其渲染为 HTML 网页:
main = do let movies = [ "2001: A Space Odyssey" , "Watchmen" , "GoldenEye" ] let html = renderHtml $ dataInList "list of movies" movies BSL.writeFile "index.html" $ html
-
运行代码以生成 HTML 文件,并使用浏览器打开,如下所示:
$ runhaskell Main.hs
输出结果如下:
另见
要将数据呈现为 LaTeX 文档并最终生成 PDF,请参考以下创建一个 LaTeX 表格来展示结果的做法。
创建一个 LaTeX 表格来展示结果
本做法将通过编程方式创建一个 LaTeX 表格,以便于文档的创建。我们可以从 LaTeX 代码生成 PDF 并随意分享。
准备工作
从 cabal 安装HaTeX
,Haskell LaTeX 库:
$ cabal install LaTeX
如何操作…
创建一个名为Main.hs
的文件,并按照以下步骤进行:
-
按照以下方式导入库:
{-# LANGUAGE OverloadedStrings #-} import Text.LaTeX import Text.LaTeX.Base.Class import Text.LaTeX.Base.Syntax import qualified Data.Map as M
-
按照以下规格保存一个 LaTeX 文件:
main :: IO () main = execLaTeXT myDoc >>= renderFile "output.tex"
-
按照以下方式定义文档,文档被分为前言和正文:
myDoc :: Monad m => LaTeXT_ m myDoc = do thePreamble document theBody
-
前言部分包含作者数据、标题、格式选项等内容,如下代码所示:
thePreamble :: Monad m => LaTeXT_ m thePreamble = do documentclass [] article author "Dr. Databender" title "Data Analyst"
-
按照以下方式定义我们希望转换为 LaTeX 表格的数据列表:
myData :: [(Int,Int)] myData = [ (1, 50) , (2, 100) , (3, 150)]
-
按照以下方式定义正文:
theBody :: Monad m => LaTeXT_ m theBody = do
-
设置标题和章节,并按照以下代码片段构建表格:
maketitle section "Fancy Data Table" bigskip center $ underline $ textbf "Table of Points" center $ tabular Nothing [RightColumn, VerticalLine, LeftColumn] $ do textbf "Time" & textbf "Cost" lnbk hline mapM_ (\(t, c) -> do texy t & texy c; lnbk) myData
-
运行以下命令后,我们可以获取 PDF 并查看:
$ runhaskell Main.hs $ pdflatex output.tex
输出结果如下:
另见
要构建一个网页,请参考前面的做法,标题为在 HTML 网页中展示结果。
使用文本模板个性化消息
有时我们有一个包含大量用户名和相关数据的列表,并且希望单独向每个人发送消息。本做法将创建一个文本模板,该模板将从数据中填充。
准备工作
使用 cabal 安装template
库:
$ cabal install template
如何操作…
在一个名为Main.hs
的新文件中执行以下步骤:
-
按如下方式导入相关库:
{-# LANGUAGE OverloadedStrings #-} import qualified Data.ByteString.Lazy as S import qualified Data.Text as T import qualified Data.Text.IO as TIO import qualified Data.Text.Lazy.Encoding as E import qualified Data.ByteString as BS import Data.Text.Lazy (toStrict) import Data.Text.Template
-
定义我们处理的数据如下:
myData = [ [ ("name", "Databender"), ("title", "Dr.") ], [ ("name", "Paragon"), ("title", "Master") ], [ ("name", "Marisa"), ("title", "Madam") ] ]
-
定义数据模板如下:
myTemplate = template "Hello $title $name!"
-
创建一个辅助函数,将数据项转换为模板,如下所示:
context :: [(T.Text, T.Text)] -> Context context assocs x = maybe err id . lookup x $ assocs where err = error $ "Could not find key: " ++ T.unpack x
-
将每个数据项与模板匹配,并将所有内容打印到文本文件中,如下代码片段所示:
main :: IO () main = do let res = map (\d -> toStrict ( render myTemplate (context d) )) myData TIO.writeFile "messages.txt" $ T.unlines res
-
运行代码以查看生成的文件:
$ runhaskell Main.hs $ cat messages.txt Hello Dr. Databender! Hello Master Paragon! Hello Madam Marisa!
将矩阵值导出到文件
在数据分析和机器学习中,矩阵是一种常见的数据结构,经常需要导入和导出到程序中。在这个方案中,我们将使用 Repa I/O 库导出一个示例矩阵。
准备工作
使用 cabal 安装repa-io
库,如下所示:
$ cabal install repa-io
如何做……
创建一个新文件,我们命名为Main.hs
,并插入接下来步骤中解释的代码:
-
按如下方式导入相关库:
import Data.Array.Repa.IO.Matrix import Data.Array.Repa
-
定义一个 4 x 3 的矩阵,如下所示:
x :: Array U DIM2 Int x = fromListUnboxed (Z :. (4::Int) :. (3::Int)) [ 1, 2, 9, 10 , 4, 3, 8, 11 , 5, 6, 7, 12 ]
-
将矩阵写入文件,如下所示:
main = writeMatrixToTextFile "output.dat" x
工作原理……
矩阵简单地表示为其元素的列表,按行优先顺序排列。文件的前两行定义了数据类型和维度。
还有更多内容……
要从此文件中读取矩阵,我们可以使用readMatrixFromTextFile
函数来检索二维矩阵。更多关于此包的文档可以在hackage.haskell.org/package/repa-io
找到。