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函数,请参考在一个编辑距离内查找字符串的配方。