Clojure-数据科学-全-
Clojure 数据科学(全)
原文:
annas-archive.org/md5/bd3fb3fcef82cc10763b55da27a85129
译者:飞龙
前言
“统计思维有一天将像读写能力一样,成为高效公民所必需的。” | ||
---|---|---|
--H. G. Wells | ||
“我有一个很好的主题[统计学]可以写,但我深感我的文学能力不足,无法在不牺牲准确性和彻底性的情况下使其易于理解。” | ||
--Sir Francis Galton |
在网络上搜索“数据科学韦恩图”会返回许多关于成为一名有效数据科学家所需技能的解读(看起来数据科学评论者特别喜欢韦恩图)。作者和数据科学家 Drew Conway 在 2010 年提出了原型图,将数据科学置于黑客技能、实质性专业知识(即学科领域理解)和数学统计知识的交集处。介于黑客技能和实质性专业知识之间——那些缺乏扎实数学和统计学知识的从业者——存在着“危险区”。
五年过去了,随着越来越多的开发者希望填补数据科学技能的短缺,统计学和数学教育比以往任何时候都更加重要,它能帮助开发者摆脱这一危险区域。因此,当 Packt 出版社邀请我为 Clojure 程序员编写一本适合的数据科学书籍时,我欣然同意。除了意识到此类书籍的必要性外,我还将其视为一个机会,来巩固我作为自己基于 Clojure 的数据分析公司 CTO 所学到的许多知识。最终结果是这本书,正是我希望在起步之前就能读到的书。
《数据科学与 Clojure》旨在成为一本远远超过 Clojure 程序员的统计学书籍。数据科学在众多领域的广泛传播,很大程度上得益于机器学习的巨大力量。在本书中,我将展示如何使用纯 Clojure 函数和第三方库来构建机器学习模型,解决回归、分类、聚类和推荐等主要任务。
对于数据科学家来说,可以扩展到非常大数据集的处理方法,所谓的“大数据”,尤为重要,因为它们可以揭示在较小样本中丢失的细微之处。本书展示了如何使用 Clojure 简洁地表达要在 Hadoop 和 Spark 分布式计算框架上运行的作业,并且通过使用专门的外部库和通用优化技术,来结合机器学习。
最重要的是,本书不仅旨在让读者了解如何执行特定类型的分析,更希望让你理解这些技术为何有效。除了提供实践知识(本书几乎每个概念都以可运行的示例呈现)外,我还希望解释理论,帮助你将一个原理应用到相关问题中。我希望这种方法能够帮助你在未来的各种情况中有效地应用统计思维,无论你是否决定从事数据科学职业。
本书内容
第一章,统计学,介绍了 Incanter,Clojure 的主要统计计算库,贯穿全书使用。通过引用来自英国和俄罗斯的选举数据,我们演示了如何使用汇总统计和统计分布的价值,同时展示了多种对比可视化。
第二章,推断,介绍了样本与总体、统计量与参数的区别。我们将假设检验作为一种正式的方法,确定在 A/B 测试网站设计的背景下差异是否显著。我们还讨论了样本偏差、效应大小以及多重检验问题的解决方案。
第三章,相关性,展示了我们如何发现变量之间的线性关系,并利用该关系预测某些变量在已知其他变量的情况下的值。我们实现了线性回归——一种机器学习算法——以预测奥林匹克游泳运动员的体重,基于他们的身高,并仅使用核心 Clojure 函数。随后,我们使用矩阵和更多数据来改进模型,提升其准确性。
第四章,分类,描述了如何实现几种不同类型的机器学习算法(逻辑回归、朴素贝叶斯、C4.5 和随机森林),以预测泰坦尼克号乘客的生存率。我们学习了适用于类别数据而非连续值的统计显著性检验方法,解释了在训练机器学习模型时可能遇到的各种问题,如偏差和过拟合,并演示了如何使用 clj-ml 机器学习库。
第五章,大数据,展示了 Clojure 如何利用各种规模计算机的并行能力,通过 reducers 库,并如何将这些技术扩展到 Hadoop 集群中的机器,结合 Tesser 和 Parkour。通过使用来自 IRS 的 ZIP 代码级别税收数据,我们展示了如何以可扩展的方式执行统计分析和机器学习。
第六章,聚类,展示了如何使用 Hadoop 和 Java 机器学习库 Mahout 识别具有相似主题的文本文件。我们描述了与文本处理相关的各种技术以及与聚类相关的更一般性概念。我们还介绍了 Parkour 的一些高级功能,帮助从 Hadoop 作业中获得最佳性能。
第七章, 推荐系统,介绍了处理推荐问题的多种不同方法。除了使用核心 Clojure 函数实现推荐系统外,我们还通过使用主成分分析和奇异值分解来解决降维问题,以及使用布隆过滤器和 MinHash 算法进行概率集压缩。最后,我们介绍了用于 Spark 分布式计算框架的 Sparkling 和 MLlib 库,并使用它们通过交替最小二乘法生成电影推荐。
第八章, 网络分析,展示了分析图结构数据的多种方法。我们使用 Loom 库演示了遍历方法,然后展示了如何使用 Spark 的 Glittering 和 GraphX 库来发现社交网络中的社区和影响者。
第九章, 时间序列,演示了如何拟合曲线以处理简单的时间序列数据。通过使用月度航空公司乘客数量数据,我们展示了如何通过训练自回归滑动*均模型来预测更复杂序列的未来值。我们通过实现一种称为最大似然估计的参数优化方法,并借助 Apache Commons Math 库来完成这一过程。
第十章, 可视化,展示了如何使用 Clojure 库 Quil 创建自定义可视化图表,以便绘制 Incanter 未提供的图表,并制作能清晰传达发现结果的吸引人图形,无论你的听众背景如何。
本书所需的工具
每章的代码都已作为项目在 GitHub 上发布,地址为 github.com/clojuredatascience
。你可以从该网站下载示例代码的压缩包,或使用 Git 命令行工具进行克隆。所有书中的示例都可以按照第一章,统计学,中描述的方式使用 Leiningen 构建工具进行编译和运行。
本书假设你已经能够使用 Leiningen 编译和运行 Clojure 代码(leiningen.org/
)。如果你还没有设置好,参考 Leiningen 网站进行配置。
此外,许多示例章节的代码使用了外部数据集。如果可能,这些数据集已与示例代码一起提供。如果无法提供,数据的下载说明已经包含在示例代码的 README 文件中。相关的 Bash 脚本也已与示例代码一起提供,用于自动化此过程。只要安装了 curl、wget、tar、gzip 和 unzip 等工具,Linux 和 OS X 用户可以按照相关章节的说明直接运行这些脚本。Windows 用户可能需要安装 Cygwin 等 Linux 模拟器(www.cygwin.com/
)来运行这些脚本。
适合谁阅读
本书面向中级和高级 Clojure 程序员,旨在帮助他们建立统计学知识,应用机器学习算法,或使用 Hadoop 和 Spark 处理大量数据。许多有志成为数据科学家的读者也会从学习这些技能中受益,《数据科学的 Clojure》应按顺序从头到尾阅读。按此方式阅读的读者将发现,每一章都在前一章的基础上进一步展开。
如果你还不熟悉阅读 Clojure 代码,可能会觉得本书特别具有挑战性。幸运的是,现在有许多优秀的资源可以帮助学习 Clojure,我在这里并不重复这些资源的内容。写作时,《勇敢与真诚的 Clojure》(www.braveclojure.com/
)是一个很棒的免费学习资源。请访问clojure.org/getting_started
以获得适合新手的其他书籍和在线教程链接。
约定
本书中会使用多种文本样式,区分不同类型的信息。以下是这些样式的一些示例及其含义解释。
文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟网址、用户输入和 Twitter 用户名将按如下方式显示:“每个示例都是cljds.ch1.examples
命名空间中的一个函数,可以执行。”
代码块的格式如下:
(defmulti load-data identity)
(defmethod load-data :uk [_]
(-> (io/resource "UK2010.xls")
(str)
(xls/read-xls)))
当我们希望特别提醒你注意代码块中的某个部分时,相关行或项目将以粗体显示:
(q/fill (fill-fn x y))
(q/rect x-pos y-pos x-scale y-scale))
(q/save "heatmap.png"))]
(q/sketch :setup setup :size size))
任何命令行输入或输出都将按以下格式书写:
lein run –e 1.1
新术语和重要单词以粗体显示。你在屏幕上看到的单词,例如在菜单或对话框中,显示为这样的格式:“每次按下新样本按钮时,都会生成一对来自指数分布的新样本,样本的总体均值从滑块中获取。”
注意
警告或重要说明通常会以框体形式出现。
提示
提示和技巧通常以这种形式出现。
读者反馈
我们始终欢迎读者的反馈。让我们知道你对本书的看法——你喜欢或不喜欢的地方。读者反馈对我们非常重要,它帮助我们开发出你会真正受益的书籍。
要向我们发送一般反馈,只需通过电子邮件 <[email protected]>
与我们联系,并在邮件主题中提到书名。
如果你在某个主题方面有专业知识并且有兴趣参与写作或贡献一本书,请查看我们的作者指南:www.packtpub.com/authors。
客户支持
现在,既然你已成为一本 Packt 书籍的骄傲拥有者,我们为你提供了许多帮助,让你从购买中获得最大收益。
下载示例代码
你可以从你的账户中下载所有你购买的 Packt Publishing 书籍的示例代码文件,访问www.packtpub.com
。如果你是在其他地方购买的本书,可以访问www.packtpub.com/support
并注册以便将文件直接通过电子邮件发送给你。
下载本书的彩色图片
我们还为你提供了一个 PDF 文件,其中包含本书中使用的截图/图表的彩色图片。这些彩色图片将帮助你更好地理解输出结果中的变化。你可以从www.packtpub.com/sites/default/files/downloads/Clojure_for_Data_Science_ColorImages.pdf
下载该文件。
勘误
尽管我们已尽最大努力确保内容的准确性,但仍然会出现错误。如果你在我们的书籍中发现错误——可能是文本或代码中的错误——我们非常感激你能向我们报告。通过这样做,你可以帮助其他读者避免沮丧,并帮助我们改进后续版本的书籍。如果你发现任何勘误,请访问www.packtpub.com/submit-errata
提交报告,选择你的书籍,点击勘误提交表格链接,并填写勘误详情。一旦你的勘误经过验证,将被接受,并上传到我们的网站,或添加到该书标题下的勘误列表中。
要查看之前提交的勘误,访问www.packtpub.com/books/content/support
并在搜索框中输入书名。所需的信息将在勘误部分显示。
盗版
互联网版权物品盗版问题在所有媒体上都在持续发生。在 Packt,我们非常重视版权和许可证的保护。如果你在互联网上发现我们作品的任何非法复制,请立即提供该位置地址或网站名称,以便我们采取相应措施。
请通过<[email protected]>
与我们联系,并附上涉嫌盗版材料的链接。
我们感谢你在保护作者权益和我们提供有价值内容方面的帮助。
问题
如果你对本书的任何部分有疑问,可以通过<[email protected]>
与我们联系,我们将尽力解决问题。
第一章. 统计学
“投票的人决定不了什么,计算票数的人决定了一切。” | ||
---|---|---|
--约瑟夫·斯大林 |
在接下来的十章中,我们将尝试在数据科学的 Clojure中探索一条大体线性的路径。事实上,我们会发现这条路径并不是那么线性,细心的读者应当注意到沿途会有许多反复出现的主题。
描述性统计学关注的是总结数字序列,它们将在本书的每一章中以某种程度出现。在本章中,我们将通过实现函数来计算数字序列的均值、中位数、方差和标准差,为后续内容奠定基础。在此过程中,我们将尽力消除对数学公式解释的恐惧。
一旦我们有多个数字需要分析,询问这些数字是如何分布的就变得有意义了。你可能已经听过像“长尾效应”和“80/20 法则”这样的表达。它们关注的是数字在一个范围内的分布情况。本章中,我们将展示分布的价值,并介绍最有用的分布:正态分布。
分布的研究得到了可视化的大力帮助,为此我们将使用 Clojure 库 Incanter。我们将展示如何使用 Incanter 加载、转换和可视化真实数据。我们将比较两次国家选举的结果——2010 年英国大选和 2011 年俄罗斯总统选举——并看看即使是基础分析也能提供潜在欺诈行为的证据。
下载示例代码
本书的所有示例代码都可以在 Packt 出版公司的官方网站www.packtpub.com/support
或 GitHub 上github.com/clojuredatascience
找到。每章的示例代码都有自己的仓库。
注意
第一章的示例代码,统计学可以从github.com/clojuredatascience/ch1-statistics
下载。
可执行示例会定期出现在所有章节中,要么演示刚刚解释的代码效果,要么演示已引入的统计原理。所有示例函数的名称都以ex-
开头,并且在每章中按顺序编号。所以,第一章的第一个可运行示例统计学名为ex-1-1
,第二个名为ex-1-2
,依此类推。
运行示例
每个示例是cljds.ch1.examples
命名空间中的一个函数,可以通过两种方式运行——要么从REPL,要么通过Leiningen在命令行运行。如果你想在 REPL 中运行示例,可以执行:
lein repl
在命令行中,默认情况下,REPL 将在 examples
命名空间中打开。或者,如果你想运行某个特定的示例,可以执行:
lein run –-example 1.1
或者使用单个字母的等效命令:
lein run –e 1.1
本书假设你对基本的命令行操作有一定了解。只需能够运行 Leiningen 和 Shell 脚本即可。
提示
如果你在任何阶段遇到困难,请参考本书的维基:wiki.clojuredatascience.com
。维基将提供已知问题的故障排除提示,包括在不同*台上运行示例的建议。
事实上,Shell 脚本仅用于自动从远程位置获取数据。本书的维基也会为不愿意或无法执行 Shell 脚本的读者提供替代的说明。
下载数据
本章的数据集由维也纳医科大学的复杂系统研究小组提供。我们将进行的分析与他们的研究紧密相连,旨在确定全球各国全国选举中系统性选举舞弊的信号。
注意
如需了解更多关于研究的信息,以及下载其他数据集的链接,请访问本书的维基或研究小组的网站:www.complex-systems.meduniwien.ac.at/elections/election.html
。
在本书中,我们将使用大量数据集。在可能的情况下,我们已将数据与示例代码一起提供。如果由于数据量太大或许可限制无法提供数据,我们则提供了下载数据的脚本。
第一章,统计学就是这样的一章。如果你已经克隆了该章节的代码,并打算跟随示例进行操作,请通过在项目目录中的命令行执行以下命令来下载数据:
script/download-data.sh
脚本将会下载并解压样本数据到项目的数据目录中。
提示
如果你在运行下载脚本时遇到困难,或者希望按照手动说明进行操作,请访问本书的维基:wiki.clojuredatascience.com
获取帮助。
我们将在下一节开始调查数据。
检查数据
在本章以及本书的许多其他章节中,我们将使用 Incanter 库(incanter.org/
)来加载、处理和显示数据。
Incanter 是一套模块化的 Clojure 库,提供统计计算和可视化功能。它模仿了广受欢迎的数据分析环境 R,将 Clojure 的强大功能、交互式 REPL 和处理数据的强大抽象结合在一起。
Incanter 的每个模块专注于特定的功能领域。例如,incanter-stats
包含一套相关函数,用于分析数据并生成摘要统计信息,而incanter-charts
提供大量的可视化功能。incanter-core
提供了最基本且通常有用的用于数据转换的函数。
每个模块可以单独包含在你的代码中。如果需要访问统计、图表和 Excel 功能,你可以在project.clj
中包含以下内容:
:dependencies [[incanter/incanter-core "1.5.5"]
[incanter/incanter-stats "1.5.5"]
[incanter/incanter-charts "1.5.5"]
[incanter/incanter-excel "1.5.5"]
...]
如果你不介意包含比所需更多的库,你也可以直接包含完整的 Incanter 分发包:
:dependencies [[incanter/incanter "1.5.5"]
...]
Incanter 的核心概念是数据集——一个包含行和列的结构。如果你有关系型数据库的经验,可以将数据集视为一个表格。数据集中的每一列都有名称,数据集中的每一行都有与其他行相同数量的列。有几种方式可以将数据加载到 Incanter 数据集中,具体使用哪种方式取决于我们的数据存储方式:
-
如果我们的数据是文本文件(CSV 或制表符分隔文件),我们可以使用
incanter-io
中的read-dataset
函数。 -
如果我们的数据是 Excel 文件(例如,
.xls
或.xlsx
文件),我们可以使用incanter-excel
中的read-xls
函数。 -
对于任何其他数据源(外部数据库、网站等),只要我们能将数据转换成 Clojure 数据结构,就可以使用
incanter-core
中的dataset
函数来创建数据集。
本章使用了 Excel 数据源,因此我们将使用read-xls
函数。该函数需要一个必需的参数——要加载的文件——以及一个可选的关键字参数,用于指定工作表的编号或名称。我们所有的示例只有一个工作表,因此我们只需提供文件参数作为字符串:
(ns cljds.ch1.data
(:require [clojure.java.io :as io]
[incanter.core :as i]
[incanter.excel :as xls]))
通常情况下,我们不会在示例代码中重复命名空间声明。这是为了简洁,并且因为所需的命名空间通常可以通过引用它们的符号推断出来。例如,在本书中,我们将始终把clojure.java.io
称为io
,将incanter.core
称为I
,将incanter.excel
称为xls
,无论何时使用它们。
在本章中,我们将加载多个数据源,因此我们在cljds.ch1.data
命名空间中创建了一个名为load-data
的多方法:
(defmulti load-data identity)
(defmethod load-data :uk [_]
(-> (io/resource "UK2010.xls")
(str)
(xls/read-xls)))
在上面的代码中,我们定义了load-data
多方法,它根据第一个参数的identity
进行分派。我们还定义了当第一个参数为:uk
时被调用的实现。因此,调用(load-data :uk)
将返回一个包含英国数据的 Incanter 数据集。在本章后面,我们将为其他数据集定义额外的load-data
实现。
UK2010.xls
电子表格的第一行包含列名。Incanter 的 read-xls
函数会将这些列名作为返回数据集的列名。让我们现在通过检查它们来开始探索数据——incanter.core
中的 col-names
函数将列名作为向量返回。在接下来的代码中(以及本书中,我们使用来自 incanter.core
命名空间的函数时),我们将其命名为 i
:
(defn ex-1-1 []
(i/col-names (load-data :uk)))
如前所述,在运行示例时,前缀为ex-
的函数可以像下面这样通过 Leiningen 在命令行上运行:
lein run –e 1.1
前述命令的输出应该是以下 Clojure 向量:
["Press Association Reference" "Constituency Name" "Region" "Election Year" "Electorate" "Votes" "AC" "AD" "AGS" "APNI" "APP" "AWL" "AWP" "BB" "BCP" "Bean" "Best" "BGPV" "BIB" "BIC" "Blue" "BNP" "BP Elvis" "C28" "Cam Soc" "CG" "Ch M" "Ch P" "CIP" "CITY" "CNPG" "Comm" "Comm L" "Con" "Cor D" "CPA" "CSP" "CTDP" "CURE" "D Lab" "D Nat" "DDP" "DUP" "ED" "EIP" "EPA" "FAWG" "FDP" "FFR" "Grn" "GSOT" "Hum" "ICHC" "IEAC" "IFED" "ILEU" "Impact" "Ind1" "Ind2" "Ind3" "Ind4" "Ind5" "IPT" "ISGB" "ISQM" "IUK" "IVH" "IZB" "JAC" "Joy" "JP" "Lab" "Land" "LD" "Lib" "Libert" "LIND" "LLPB" "LTT" "MACI" "MCP" "MEDI" "MEP" "MIF" "MK" "MPEA" "MRLP" "MRP" "Nat Lib" "NCDV" "ND" "New" "NF" "NFP" "NICF" "Nobody" "NSPS" "PBP" "PC" "Pirate" "PNDP" "Poet" "PPBF" "PPE" "PPNV" "Reform" "Respect" "Rest" "RRG" "RTBP" "SACL" "Sci" "SDLP" "SEP" "SF" "SIG" "SJP" "SKGP" "SMA" "SMRA" "SNP" "Soc" "Soc Alt" "Soc Dem" "Soc Lab" "South" "Speaker" "SSP" "TF" "TOC" "Trust" "TUSC" "TUV" "UCUNF" "UKIP" "UPS" "UV" "VCCA" "Vote" "Wessex Reg" "WRP" "You" "Youth" "YRDPL"]
这是一个非常宽的数据集。数据文件中的前六列描述如下;后续列按党派细分投票数:
-
新闻社参考:这是一个识别选区(投票区,由一名议员代表)的数字
-
选区名称:这是给投票区(选区)起的常用名称
-
区域:这是选区所在的英国地理区域
-
选举年份:这是选举举行的年份
-
选民:这是选区内有资格投票的总人数
-
投票数:这是总投票数
每当我们面对新数据时,理解数据是非常重要的。如果没有详细的数据定义,我们可以通过验证自己对数据的假设来开始理解它。例如,我们预期这个数据集包含关于 2010 年选举的信息,那么让我们先回顾一下 选举年份
列的内容。
Incanter 提供了 i/$
函数(i
,如前所述,表示 incanter.core
命名空间)用于从数据集中选择列。我们将在本章中经常遇到这个函数——它是 Incanter 从各种数据表示中选择列的主要方式,并且提供了多个不同的重载。目前,我们只需要提供我们想提取的列名和要提取的 dataset:
(defn ex-1-2 []
(i/$ "Election Year" (load-data :uk)))
;; (2010.0 2010.0 2010.0 2010.0 2010.0 ... 2010.0 2010.0 nil)
这些年份作为一个单一的值序列返回。由于数据集包含很多行,输出可能难以理解。为了知道列中包含哪些唯一值,我们可以使用 Clojure 的核心函数 distinct
。使用 Incanter 的一个优点是它的有用数据操作函数增强了 Clojure 已经提供的函数,正如下面的示例所示:
(defn ex-1-3 []
(->> (load-data :uk)
(i/$ "Election Year")
(distinct)))
;; (2010 nil)
2010
年份在很大程度上确认了我们的预期——这些数据来自 2010
。然而,nil
值则出乎意料,可能表明数据存在问题。
我们目前还不知道数据集中有多少个 nil 值,确定这一点可能帮助我们决定接下来该做什么。计数这类值的一个简单方法是使用核心库函数 frequencies
,它返回一个值与计数的映射:
(defn ex-1-4 [ ]
(->> (load-data :uk)
(i/$ "Election Year")
(frequencies)))
;; {2010.0 650 nil 1}
在前面的示例中,我们使用了 Clojure 的线程最后宏 ->>
来将多个函数连接在一起,提升可读性。
提示
除了 Clojure 大量的核心数据处理函数外,像前面讨论的宏——包括线程最后宏 ->>
——也是使用 Clojure 进行数据分析的另一个重要原因。在本书中,我们将看到 Clojure 如何使即使是复杂的分析也能简洁且易于理解。
我们很快就能确认,2010 年英国有 650 个选区,称为选区。像这样的领域知识在对新数据进行合理性检查时非常宝贵。因此,nil
值很可能是多余的,可以删除。我们将在下一节看到如何做这件事。
数据清洗
有一个常见的统计数据表明,数据科学家至少 80% 的工作是数据清洗。这是检测潜在的损坏或错误数据,并进行修正或过滤的过程。
注意
数据清洗是处理数据时最重要(也是最耗时)的步骤之一。它是确保后续分析基于有效、准确且一致的数据进行的关键步骤。
选举年列末尾的 nil
值可能表示需要清除的脏数据。我们已经看到,通过 Incanter 的 i/$
函数可以过滤 列 数据。要过滤 行 数据,我们可以使用 Incanter 的 i/query-dataset
函数。
我们通过传递一个包含列名和谓词的 Clojure 映射,让 Incanter 知道我们希望过滤哪些行。只有所有谓词返回 true 的行才会被保留。例如,要从数据集中仅选择 nil
值:
(-> (load-data :uk)
(i/query-dataset {"Election Year" {:$eq nil}}))
如果你了解 SQL,你会发现这与 WHERE
子句非常相似。事实上,Incanter 还提供了 i/$where
函数,这是 i/query-dataset
的别名,反转了参数的顺序。
查询是一个将列名映射到谓词的映射,每个谓词本身是一个操作符到操作数的映射。可以通过指定多个列和多个操作符来构建复杂的查询。查询操作符包括:
-
:$gt
大于 -
:$lt
小于 -
:$gte
大于或等于 -
:$lte
小于或等于 -
:$eq
等于 -
:$ne
不等于 -
:$in
用于测试是否为某集合的成员 -
:$nin
用于测试是否不为某集合的成员 -
:$fn
一个谓词函数,应该返回 true 以保留该行
如果内置操作符不足以满足需求,最后一个操作符提供了传递自定义函数的能力。
我们将继续使用 Clojure 的线程最后宏(thread-last macro)来使代码的意图更清晰,并使用 i/to-map
函数将行返回为键值对映射:
(defn ex-1-5 []
(->> (load-data :uk)
(i/$where {"Election Year" {:$eq nil}})
(i/to-map)))
;; {:ILEU nil, :TUSC nil, :Vote nil ... :IVH nil, :FFR nil}
仔细查看结果,很明显这一行中所有(除了一个)列的值都是nil
。事实上,经过进一步的探索,确认非nil
行是一个汇总总数,应该从数据中删除。我们可以通过更新谓词映射,使用:$ne
操作符来删除有问题的行,只返回选举年份不等于nil
的行:
(->> (load-data :uk)
(i/$where {"Election Year" {:$ne nil}}))
上述函数是我们几乎总是希望在使用数据之前调用的。实现这一点的一种方式是添加另一个load-data
多方法的实现,其中也包括此过滤步骤:
(defmethod load-data :uk-scrubbed [_]
(->> (load-data :uk)
(i/$where {"Election Year" {:$ne nil}})))
现在,无论我们写什么代码,都可以选择引用:uk
或:uk-scrubbed
数据集。
通过始终加载源文件并在其上执行数据清洗,我们保留了我们所做转换的审计记录。这使我们——以及未来的代码读者——能够清楚地了解对源数据做了哪些调整。它还意味着,如果我们需要使用新的源数据重新运行分析,我们可能只需将新文件加载到现有文件的位置。
描述性统计
描述性统计是用来总结和描述数据的数字。在下一章,我们将关注更复杂的分析方法——所谓的推论统计,但现在我们只限于简单地描述文件中数据的观察内容。
为了演示我们的意思,我们来看一下数据中的Electorate
列。该列列出了每个选区注册选民的总数:
(defn ex-1-6 []
(->> (load-data :uk-scrubbed)
(i/$ "Electorate")
(count)))
;; 650
我们已经从数据集中过滤掉了nil
字段;上述代码应该返回一个包含650
个数字的列表,代表每个英国选区的选民。
描述性统计,也叫做汇总统计,是衡量数值序列特征的方式。它们有助于表征序列,并可以作为进一步分析的指导。让我们从计算数值序列中最基本的两个统计量开始——均值和方差。
均值
测量数据集*均值最常见的方法是均值。它实际上是衡量数据集中趋势的几种方法之一。均值,或者更准确地说,是算术均值,是一种直接的计算方法——简单地将数值加起来并除以数量——但尽管如此,它的数学符号看起来还是让人有些畏惧:
其中被读作x-bar,是常用于表示均值的数学符号。
对于从数学或科学以外领域进入数据科学的程序员来说,这种符号可能会让人感到困惑和陌生。其他人可能对这种符号完全熟悉,他们可以放心跳过下一节。
解释数学符号
尽管数学符号看起来可能晦涩难懂,实际上只有少数几个符号会在本书的公式中频繁出现。
Σ 读作 sigma,意思是 和。当你看到它出现在数学符号中时,意味着一个序列正在被求和。位于 sigma 上下的符号表示我们将要进行求和的范围。它们类似于 C 风格的 for
循环,在之前的公式中,表示我们会从 i=1 到 i=n 进行求和。按照惯例,n 是序列的长度,且数学符号中的序列是从 1 开始索引的,而不是从 0 开始,因此从 1 到 n 求和意味着我们在求整个序列的和。
紧跟着 sigma 的表达式是要被求和的序列。在我们之前的*均数公式中,x[i] 紧跟在 sigma 后面。由于 i 将表示从 1 到 n 的每个索引,x[i] 代表 xs 序列中的每个元素。
最后, 出现在 sigma 之前,表示整个表达式应乘以 1 除以 n(也称为 n 的倒数)。这可以简化为只除以 n。
名称 | 数学符号 | Clojure 对应 |
---|---|---|
n | (count xs) |
|
Sigma 符号 | ![]() |
(reduce + xs) |
Pi 符号 | ![]() |
(reduce * xs) |
将这一切结合起来,我们得出“将序列中的元素从第一个加到最后一个,然后除以元素的数量”。在 Clojure 中,这可以写成:
(defn mean [xs]
(/ (reduce + xs)
(count xs)))
其中,xs
代表“xs 序列”。我们可以使用新的 mean
函数来计算英国选民的*均数:
(defn ex-1-7 []
(->> (load-data :uk-scrubbed)
(i/$ "Electorate")
(mean)))
;; 70149.94
实际上,Incanter 已经在 incanter.stats
命名空间中包含了一个非常高效的计算序列*均数的函数 mean
。在本章(以及全书)中,任何使用 incanter.stats
命名空间的地方都会用 s
作为简写。
中位数
中位数是另一种常见的描述性统计量,用于衡量序列的集中趋势。如果将所有数据从低到高排序,中位数就是中间的值。如果序列中的数据点数量是偶数,中位数通常定义为中间两个值的*均数。
中位数通常用 表示,发音为 x-tilde。这是数学符号中的一个不足之处,因为没有特别标准的方式来表示中位数公式,但在 Clojure 中仍然相当直接:
(defn median [xs]
(let [n (count xs)
mid (int (/ n 2))]
(if (odd? n)
(nth (sort xs) mid)
(->> (sort xs)
(drop (dec mid))
(take 2)
(mean)))))
英国选民的中位数是:
(defn ex-1-8 []
(->> (load-data :uk-scrubbed)
(i/$ "Electorate")
(median)))
;; 70813.5
Incanter 也提供了一个用于计算中位数的函数 s/median
。
方差
均值和中位数是描述序列中间值的两种替代方法,但单独使用它们几乎无法告诉我们序列中包含的值。例如,如果我们知道一个包含 99 个值的序列的均值是 50,我们也无法仅凭此判断序列包含哪些值。
它可能包含从 1 到 99 的所有整数,或者 49 个零和 50 个 99。也许它包含了负一的值 98 次,且有一个 5000 和 48。或者,可能所有的值都是恰好 50。
序列的方差是其关于均值的“分散度”,前面每个例子的方差都会不同。在数学符号中,方差表示为:
其中,s²是通常用于表示方差的数学符号。
这个方程与之前计算均值的方程有许多相似之处。不同的是,我们不是求单一值x[i]的总和,而是求函数的总和。回忆一下,符号
表示均值,因此这个函数计算了xi相对于所有xs均值的*方偏差。
我们可以将表达式转化为一个函数
square-deviation
,然后应用于xs
序列。我们还可以利用已经创建的mean
函数,计算序列中的值的总和,并除以计数。
(defn variance [xs]
(let [x-bar (mean xs)
n (count xs)
square-deviation (fn [x]
(i/sq (- x x-bar)))]
(mean (map square-deviation xs))))
我们使用 Incanter 的i/sq
函数来计算表达式的*方。
由于我们在取均值之前已经对偏差进行了*方,因此方差的单位也会被*方,所以英国选民的方差单位是“人*方”。这种单位有些不太自然。我们可以通过取方差的*方根,使得单位再次变为“人”,结果称为标准差:
(defn standard-deviation [xs]
(i/sqrt (variance xs)))
(defn ex-1-9 []
(->> (load-data :uk-scrubbed)
(i/$ "Electorate")
(standard-deviation)))
;; 7672.77
Incanter 实现了分别计算方差和标准差的函数,分别为s/variance
和s/sd
。
分位数
中位数是计算列表中中间值的一种方法,方差则提供了一种衡量数据相对于该中点的分布情况的方式。如果整个数据的分布在零到一的尺度上表示,中位数将是位于 0.5 处的值。
例如,考虑以下数字序列:
[10 11 15 21 22.5 28 30]
序列中有七个数字,因此中位数是第四个,或者是 21。这个值也称为 0.5 分位数。我们可以通过查看 0、0.25、0.5、0.7 和 1.0 分位数,获得序列数字的更丰富画面。将这些数字放在一起,它们不仅显示了中位数,还总结了数据的范围和数字在其中的分布情况。它们有时被称为五数概括。
计算英国选民数据的五数概括的一种方法如下所示:
(defn quantile [q xs]
(let [n (dec (count xs))
i (-> (* n q)
(+ 1/2)
(int))]
(nth (sort xs) i)))
(defn ex-1-10 []
(let [xs (->> (load-data :uk-scrubbed)
(i/$ "Electorate"))
f (fn [q]
(quantile q xs))]
(map f [0 1/4 1/2 3/4 1])))
;; (21780.0 66219.0 70991.0 75115.0 109922.0)
也可以直接使用 Incanter 的s/quantile
函数计算分位数。所需的分位数序列作为关键字参数:probs
传入。
注意
Incanter 的quantile
函数使用一种称为phi-quantile的算法变体,在某些情况下,它会在连续数字之间进行线性插值。计算分位数的方式有很多种——请参阅en.wikipedia.org/wiki/Quantile
了解不同算法之间的差异。
当分位数将范围分为四个相等的范围时,它们被称为四分位数。下四分位数和上四分位数之间的差异称为四分位距,通常简写为IQR。像均值的方差一样,IQR 提供了关于中位数数据分布的度量。
分箱数据
为了理解这些方差计算所衡量的内容,我们可以使用一种称为分箱的技术。对于连续数据,使用frequencies
(正如我们在选举数据中用来计算零值一样)并不实用,因为两个值可能不完全相同。然而,通过将数据分为离散的区间,我们可以大致了解数据的结构。
分箱过程是将数值范围划分为若干个连续、等大小的较小箱子。原始数据中的每个值都会落入其中的一个箱子。通过统计每个箱子中的点数,我们可以大致了解数据的分布情况:
上述示例展示了将十五个x值分为五个相等大小的箱子。通过统计每个箱子内的点数,我们可以清楚地看到,大多数点落在中间的箱子中,而靠*边缘的箱子内的点数较少。我们可以使用以下bin
函数在 Clojure 中实现相同的功能:
(defn bin [n-bins xs]
(let [min-x (apply min xs)
max-x (apply max xs)
range-x (- max-x min-x)
bin-fn (fn [x]
(-> x
(- min-x)
(/ range-x)
(* n-bins)
(int)
(min (dec n-bins))))]
(map bin-fn xs)))
例如,我们可以将范围 0-14 分成5
个箱子,如下所示:
(bin 5 (range 15))
;; (0 0 0 1 1 1 2 2 2 3 3 3 4 4 4)
一旦我们将值分箱,我们可以再次使用frequencies
函数统计每个箱子中的点数。在以下代码中,我们使用该函数将英国选民数据分为五个箱子:
(defn ex-1-11 []
(->> (load-data :uk-scrubbed)
(i/$ "Electorate")
(bin 10)
(frequencies)))
;; {1 26, 2 450, 3 171, 4 1, 0 2}
极端箱子(0 和 4)的点数远低于中间的箱子——这些点数似乎向中位数处上升,然后又下降。在接下来的部分,我们将可视化这些点数的分布形态。
直方图
直方图是一种可视化单一数据序列分布的方法。直方图通过将连续分布进行分箱,并绘制每个箱子中点数的频率来呈现数据。直方图中每个条形的高度表示数据中有多少点落在该箱子内。
我们已经看过如何自己进行数据分箱,但incanter.charts
包含一个histogram
函数,可以通过两个步骤对数据进行分箱并可视化为直方图。在本章(以及全书)中,我们需要将incanter.charts
作为c
。
(defn ex-1-12 []
(-> (load-data :uk-scrubbed)
(i/$ "Electorate")
(c/histogram)
(i/view)))
前面的代码生成了以下图表:
我们可以通过将关键字参数:nbins
作为第二个参数传递给histogram
函数,来配置数据被分为多少个箱:
(defn ex-1-13 []
(-> (uk-electorate)
(c/histogram :nbins 200)
(i/view)))
前面的图表显示了一个单一的高峰,但相对粗略地表达了数据的形状。下面的图表显示了更精细的细节,但柱状图的体积遮蔽了分布的形状,特别是在尾部:
选择表示数据的箱数是一个精细的*衡——箱数太少,数据的形状将只会被粗略表示;箱数太多,噪声特征可能会遮蔽底层结构。
(defn ex-1-14 []
(-> (i/$ "Electorate" (load-data :uk-scrubbed))
(c/histogram :x-label "UK electorate"
:nbins 20)
(i/view)))
下面显示的是20
个柱状条的直方图:
这个包含20
个箱的最终图表似乎是目前为止最好的数据表示。
除了均值和中位数,众数是另一种衡量序列*均值的方式——它被定义为序列中最常出现的值。众数严格来说仅定义在至少有一个重复值的序列中;对于许多分布,情况并非如此,因此众数是未定义的。然而,直方图的峰值通常被称为众数,因为它对应于最流行的箱。
我们可以清晰地看到,分布在众数附*非常对称,值在两侧急剧下降,尾部较浅。这是数据遵循大致正态分布的表现。
正态分布
直方图将告诉你数据如何大致分布在其范围内,并提供一种将数据分类为几种常见分布之一的可视化手段。在数据分析中,许多分布经常出现,但没有比正态分布更为常见的,正态分布也叫做高斯分布。
注意
该分布被称为正态分布,是因为它在自然界中出现的频率非常高。伽利略注意到,他的天文测量误差遵循一个分布,即从均值的偏差较小的出现频率比偏差较大的频率更高。正是伟大的数学家高斯对这些误差的数学形状的描述,使得该分布也被称为高斯分布,以此纪念他。
分布就像是一种压缩算法:它允许将大量数据非常高效地进行总结。正态分布只需要两个参数,其他数据可以从这两个参数中进行*似——均值和标准差。
中心极限定理
正态分布的普遍性部分可以通过中心极限定理来解释。来自不同分布的数值在特定条件下会趋向于收敛到正态分布,接下来我们将展示这一点。
在编程中,一种常见的分布是均匀分布。这是由 Clojure 的rand
函数生成的数字分布:对于一个公*的随机数生成器,所有数字生成的机会是相等的。我们可以通过多次生成一个 0 到 1 之间的随机数并绘制结果,来在直方图中可视化这一点。
(defn ex-1-15 []
(let [xs (->> (repeatedly rand)
(take 10000))]
(-> (c/histogram xs
:x-label "Uniform distribution"
:nbins 20)
(i/view))))
前面的代码将生成以下直方图:
每个直方图的条形高度大致相同,对应于生成落入每个区间的数字的均等概率。条形的高度并不完全相同,因为均匀分布描述的是我们的随机抽样无法精确反映的理论输出。在接下来的几章中,我们将学习如何精确量化理论与实践之间的差异,以确定这些差异是否足够大,值得关注。在这个案例中,它们并不大。
如果我们生成的是数列均值的直方图,结果会呈现出与之前截然不同的分布。
(defn ex-1-16 []
(let [xs (->> (repeatedly rand)
(partition 10)
(map mean)
(take 10000))]
(-> (c/histogram xs
:x-label "Distribution of means"
:nbins 20)
(i/view))))
前面的代码将生成类似于以下直方图的输出:
虽然均值接*零或一并非不可能,但这种情况极为不可能,而且随着*均数的数量和抽样*均值的数量增加,这种不可能性会变得越来越大。事实上,输出值极其接*正态分布。
这个结果——即许多小的随机波动的*均效应导致正态分布——被称为中心极限定理,有时简称为CLT,它在很大程度上解释了为什么正态分布在自然现象中如此频繁地出现。
中心极限定理直到 20 世纪才得以命名,尽管这一效应早在 1733 年就被法国数学家阿布拉罕·德·莫伊夫(Abraham de Moivre)记录下来,他用正态分布来*似公*投掷硬币时的正面出现次数。硬币投掷的结果最适合用二项分布来建模,我们将在第四章 分类中介绍二项分布。虽然中心极限定理提供了一种从*似正态分布中生成样本的方法,但 Incanter 的distributions
命名空间提供了从多种分布中高效生成样本的函数,包括正态分布:
(defn ex-1-17 []
(let [distribution (d/normal-distribution)
xs (->> (repeatedly #(d/draw distribution))
(take 10000))]
(-> (c/histogram xs
:x-label "Normal distribution"
:nbins 20)
(i/view))))
前面的代码生成了以下直方图:
d/draw
函数将从提供的分布返回一个样本。d/normal-distribution
的默认均值和标准差分别为零和一。
庞加莱的面包师
有一个故事说,虽然几乎可以确定是虚构的,但它使我们更详细地了解中心极限定理如何帮助我们推断分布的形成方式。这个故事涉及到著名的十九世纪法国多面手亨利·庞加莱,据说他一年中每天称量自己的面包。
烘焙是一个受监管的行业,庞加莱发现,虽然面包的重量符合正态分布,但峰值在 950 克,而不是宣传的 1 公斤。他再次向当局举报面包师,于是面包师被罚款。
第二年,庞加莱继续从同一位面包师那里购买面包并称重。他发现均值现在是 1 公斤,但围绕均值的分布不再对称。分布向右偏,与面包师只给庞加莱最重的面包相符。庞加莱再次向当局举报面包师,面包师第二次被罚款。
现在不必关心这个故事是否真实;这个故事仅用来说明一个关键点——一系列数字的分布可以告诉我们生成它的过程中的一些重要信息。
生成分布
为了发展我们对正态分布和方差的直觉,让我们使用 Incanter 的分布函数来模拟一个诚实和不诚实的面包师。我们可以将诚实的面包师建模为均值为 1000 的正态分布,对应于 1 公斤的公*面包。我们假设在烘焙过程中存在方差,导致标准差为 30 克。
(defn honest-baker [mean sd]
(let [distribution (d/normal-distribution mean sd)]
(repeatedly #(d/draw distribution))))
(defn ex-1-18 []
(-> (take 10000 (honest-baker 1000 30))
(c/histogram :x-label "Honest baker"
:nbins 25)
(i/view)))
上述代码将生成类似以下直方图的输出:
现在,让我们模拟一个只卖最重的面包的面包师。我们将序列分成 13 个一组(“面包师的一打”),然后选择最大值:
(defn dishonest-baker [mean sd]
(let [distribution (d/normal-distribution mean sd)]
(->> (repeatedly #(d/draw distribution))
(partition 13)
(map (partial apply max)))))
(defn ex-1-19 []
(-> (take 10000 (dishonest-baker 950 30))
(c/histogram :x-label "Dishonest baker"
:nbins 25)
(i/view)))
上述代码将生成类似以下直方图的输出:
显而易见,这个直方图看起来与我们之前看到的不太一样。均值仍然是 1 公斤,但围绕均值的数值分布不再对称。我们称这个直方图显示出一个偏态正态分布。
偏度
偏度是分布围绕其众数的不对称性的名称。负偏态或左偏态表明图形下众数左侧的面积较大。正偏态或右偏态表明图形下众数右侧的面积较大。
Incanter 在stats
命名空间中有一个内置函数用于测量偏度:
(defn ex-1-20 []
(let [weights (take 10000 (dishonest-baker 950 30))]
{:mean (mean weights)
:median (median weights)
:skewness (s/skewness weights)}))
上面的示例显示,不诚实面包师输出的偏度约为 0.4,量化了在直方图中显示的偏斜。
分位数-分位数图
我们在本章前面已经遇到过分位数,作为描述数据分布的一种方式。回想一下,quantile
函数接受介于零和一之间的数字,并返回该点的序列值。0.5 对应于中位数值。
将数据的分位数与正态分布的分位数进行绘制,可以让我们看到测量数据与理论分布的比较。这种图形被称为Q-Q 图,它提供了一种快速且直观的方式来判断正态性。对于接*正态分布的数据,Q-Q 图呈现直线。偏离直线的部分表明数据偏离理想化的正态分布的方式。
让我们并排绘制诚实和不诚实面包师的 Q-Q 图。Incanter 的 c/qq-plot
函数接受数据点列表,并生成一个样本分位数与理论正态分布的分位数绘制的散点图:
(defn ex-1-21 []
(->> (honest-baker 1000 30)
(take 10000)
(c/qq-plot)
(i/view))
(->> (dishonest-baker 950 30)
(take 10000)
(c/qq-plot)
(i/view)))
上述代码将生成以下图表:
诚实面包师的 Q-Q 图在前面已经展示。下面是指不诚实面包师的图:
线条弯曲表明数据是正偏的;反向弯曲则表明数据是负偏的。实际上,Q-Q 图使得我们更容易识别各种偏离标准正态分布的情况,如下图所示:
Q-Q 图比较了诚实与不诚实面包师的分布与理论正态分布的对比。在接下来的部分,我们将比较几种不同的方式来直观地比较两个(或更多)测量值序列。
比较可视化
Q-Q 图提供了一种极好的方式,用来将测量得到的经验分布与理论正态分布进行比较。如果我们想要比较两个或多个经验分布之间的关系,我们不能使用 Incanter 的 Q-Q 图表。不过,我们有其他多种选择,如接下来的两部分所示。
箱形图
箱形图,或称为箱线图,是一种可视化描述统计中的中位数和方差的方式。我们可以使用以下代码生成它们:
(defn ex-1-22 []
(-> (c/box-plot (->> (honest-baker 1000 30)
(take 10000))
:legend true
:y-label "Loaf weight (g)"
:series-label "Honest baker")
(c/add-box-plot (->> (dishonest-baker 950 30)
(take 10000))
:series-label "Dishonest baker")
(i/view)))
这将生成以下图表:
图中间的框表示四分位数范围。中位数是通过盒子中间的线,而均值则是大的黑点。对于诚实的面包师,中位数穿过圆形的中心,表明均值和中位数差不多。而对于不诚实的面包师,均值偏离中位数,表明存在偏斜。
须根表示数据的范围,异常值用空心圆表示。在一个图表中,我们比在单独的直方图或 Q-Q 图上更清楚地看到了两个分布之间的差异。
累积分布函数
累积分布函数,也称为 CDF,描述了从一个分布中抽取的值小于 x 的概率。像所有概率分布一样,它们的值在 0 和 1 之间,0 代表不可能,1 代表确定性。例如,假设我即将掷一个六面骰子。掷出小于六的概率是多少?
对于一颗公*的骰子,掷出 5 或更小的概率是 。相反,掷出 1 的概率只有
。掷出 3 或更小的结果对应着*等的几率——50% 的概率。
骰子掷出的 CDF 遵循与所有 CDF 相同的模式——对于数值范围较低的部分,CDF 接*零,表示选择该范围或以下的数字的概率较低。对于范围的高端,CDF 接*一,因为大多数从序列中抽取的值会较小。
注意
CDF 和分位数密切相关——CDF 是分位数函数的逆函数。如果 0.5 分位数对应的值是 1,000,那么 1,000 的 CDF 就是 0.5。
就像 Incanter 的 s/quantile
函数允许我们在特定点从分布中采样值一样,s/cdf-empirical
函数允许我们输入一个来自序列的值,并返回一个介于零和一之间的值。它是一个高阶函数——接受值(在此情况下是一个值的序列)并返回一个函数。然后可以多次调用返回的函数,传入不同的输入值,从而返回它们各自的 CDF。
注意
高阶函数是接受或返回函数的函数。
让我们并排绘制诚实和不诚实面包师的 CDF。我们可以使用 Incanter 的 c/xy-plot
来通过绘制源数据——来自诚实和不诚实面包师的样本——与针对经验 CDF 计算出的概率,来可视化 CDF。c/xy-plot
函数期望 x 值和 y 值作为两个单独的值序列提供。
为了在同一个图表上绘制这两个分布,我们需要能够为我们的 xy-plot
提供多个系列。Incanter 为其许多图表提供了添加附加系列的功能。对于 xy-plot
,我们可以使用函数 c/add-lines
,它的第一个参数是图表,接下来的两个参数分别是 x 系列和 y 系列的数据。你还可以传递一个可选的系列标签。我们在以下代码中这么做,以便在最终的图表上区分这两个系列:
(defn ex-1-23 []
(let [sample-honest (->> (honest-baker 1000 30)
(take 1000))
sample-dishonest (->> (dishonest-baker 950 30)
(take 1000))
ecdf-honest (s/cdf-empirical sample-honest)
ecdf-dishonest (s/cdf-empirical sample-dishonest)]
(-> (c/xy-plot sample-honest (map ecdf-honest sample-honest)
:x-label "Loaf Weight"
:y-label "Probability"
:legend true
:series-label "Honest baker")
(c/add-lines sample-dishonest
(map ecdf-dishonest sample-dishonest)
:series-label "Dishonest baker")
(i/view))))
上面的代码生成了如下图表:
尽管看起来很不一样,这个图表实际上展示了与箱线图相同的信息。我们可以看到,两条线大约在 0.5 的中位数处交叉,对应于 1,000 克。那个不诚实的线在下尾处被截断,上尾则更长,表明其分布是偏斜的。
可视化的重要性
像前面那样的简单可视化是传达大量信息的简洁方式。它们补充了我们在本章前面计算的摘要统计量,使用它们非常重要。像均值和标准差这样的统计量不可避免地会隐藏大量信息,因为它们将一系列数据压缩为一个单一的数字。
统计学家弗朗西斯·安斯科姆(Francis Anscombe)设计了一组四个散点图,称为安斯科姆四重奏,它们具有几乎相同的统计特性(包括均值、方差和标准差)。尽管如此,xs和ys的分布在视觉上是截然不同的:
数据集在绘制图表时不必经过人为设计就能揭示有价值的见解。以 2013 年波兰全国马图拉考试成绩的直方图为例:
我们可能期望学生的能力呈正态分布,实际上——除了大约 30%的急剧峰值——它确实是正态分布的。我们可以清楚地看到,考官人为地把学生的成绩推高,使其超过及格线。
实际上,从大样本中提取的序列的分布可以如此可靠,以至于任何偏离它们的情况都可能是非法活动的证据。本福德定律,也叫做首位数字定律,是一种关于大范围随机数的奇特特性。数字 1 大约有 30%的时间作为首位数字出现,而较大的数字则越来越少。比如,数字 9 作为首位数字的概率不到 5%。
注意
本福德定律以物理学家弗兰克·本福德(Frank Benford)的名字命名,他于 1938 年提出该定律,并展示了它在各种数据源中的一致性。早在 50 多年前,西蒙·纽科姆(Simon Newcomb)就曾注意到,本福德定律曾被他发现过,纽科姆观察到,他的对数表书页在数字以 1 开头的地方更为磨损。
本福德展示了该定律适用于各种各样的数据,例如电费账单、街道地址、股价、人口数据、死亡率以及河流的长度。这一定律在涵盖大范围数值的数据集中的一致性如此之高,以至于其偏离被接受作为金融欺诈审判中的证据。
可视化选民数据
让我们回到选举数据,并将我们之前创建的选民序列与理论正态分布的 CDF 进行比较。我们可以使用 Incanter 的s/cdf-normal
函数根据值序列生成正态分布的 CDF。默认均值为 0,标准差为 1,因此我们需要提供选民数据的测量均值和标准差。对于我们的选民数据,这些值分别为 70,150 和 7,679。
在本章前面,我们生成了一个经验性的 CDF。以下示例仅生成了两个 CDF,并将它们绘制在单个c/xy-plot
上:
(defn ex-1-24 []
(let [electorate (->> (load-data :uk-scrubbed)
(i/$ "Electorate"))
ecdf (s/cdf-empirical electorate)
fitted (s/cdf-normal electorate
:mean (s/mean electorate)
:sd (s/sd electorate))]
(-> (c/xy-plot electorate fitted
:x-label "Electorate"
:y-label "Probability"
:series-label "Fitted"
:legend true)
(c/add-lines electorate (map ecdf electorate)
:series-label "Empirical")
(i/view))))
前面的示例生成了以下绘图:
通过两条线的接*程度,您可以看出数据多么接*正态分布,尽管稍有偏斜。偏斜方向与我们之前绘制的不诚实面包师的 CDF 相反,因此我们的选民数据略微向左倾斜。
因为我们正在将我们的分布与理论正态分布进行比较,让我们使用 Q-Q 图,默认情况下将执行此操作:
(defn ex-1-25 []
(->> (load-data :uk-scrubbed)
(i/$ "Electorate")
(c/qq-plot)
(i/view)))
下面的 Q-Q 图更好地突显了数据中明显的左偏态:
正如我们预期的那样,曲线与本章早期不诚实的面包师 Q-Q 图相反。这表明,如果数据更接*正态分布,比我们预期的小选区数目更多。
添加列
到目前为止,本章中我们通过过滤行和列来减少数据集的大小。通常,我们会希望向数据集添加行,Incanter 支持几种方式来实现这一点。
首先,我们可以选择是替换数据集中的现有列还是追加附加列到数据集。其次,我们可以选择是直接提供新列值以替换现有列值,还是通过对数据的每一行应用函数来计算新值。
下图列出了我们的选项及相应的 Incanter 函数使用方法:
替换数据 | 追加数据 | |
---|---|---|
通过提供序列 | i/replace-column |
i/add-column |
通过应用函数 | i/transform-column |
i/add-derived-column |
当基于函数转换或派生列时,我们将传递新列的名称以创建,应用于每行的函数,以及现有列名称的序列。每个现有列中包含的值将构成函数的参数。
让我们通过一个实际示例来展示如何使用 i/add-derived-column
函数。2010 年的英国大选结果是悬浮议会,没有任何政党获得绝对多数席位。保守党和自由民主党之间形成了联合政府。在下一节中,我们将找出每个党派的支持人数,并计算其在总投票中的比例。
添加衍生列
要找出选民中投票支持保守党或自由民主党的比例,我们需要计算每个政党的得票总和。由于我们是基于现有数据创建一个新的数据字段,因此我们需要使用 i/add-derived-column
函数。
(defn ex-1-26 []
(->> (load-data :uk-scrubbed)
(i/add-derived-column :victors [:Con :LD] +)))
然而,如果我们现在运行这个操作,将会生成一个异常:
ClassCastException java.lang.String cannot be cast to java.lang.Number clojure.lang.Numbers.add (Numbers.java:126)
不幸的是,Clojure 报告错误,指出我们试图添加一个 java.lang.String
类型的值。显然,Con
或 LD
列中某个(或两个)包含了字符串值,但到底是哪个呢?我们可以再次使用频率统计来查看问题的范围:
(->> (load-data :uk-scrubbed)
($ "Con")
(map type)
(frequencies))
;; {java.lang.Double 631, java.lang.String 19}
(->> (load-data :uk-scrubbed)
($ "LD")
(map type)
(frequencies))
;; {java.lang.Double 631, java.lang.String 19}
让我们使用本章早些时候提到的 i/$where
函数,仅查看这些数据行:
(defn ex-1-27 []
(->> (load-data :uk-scrubbed)
(i/$where #(not-any? number? [(% "Con") (% "LD")]))
(i/$ [:Region :Electorate :Con :LD])))
;; | Region | Electorate | Con | LD |
;; |------------------+------------+-----+----|
;; | Northern Ireland | 60204.0 | | |
;; | Northern Ireland | 73338.0 | | |
;; | Northern Ireland | 63054.0 | | |
;; ...
这一部分的探索应该足以让我们确信,这些字段为空的原因是没有在相应选区推出候选人。我们应该过滤掉这些数据,还是认为它们的值为零呢?这是一个有趣的问题。我们选择过滤掉这些数据,因为在这些选区内,选民根本无法选择自由民主党或保守党候选人。如果我们假定为零,将人为地降低那些本可以选择其中一个政党投票的选民的*均数。
现在我们知道如何过滤掉有问题的行,接下来添加胜选者及其投票份额、选民投票率的衍生列。我们过滤数据行,仅显示那些有保守党和自由民主党候选人的行:
(defmethod load-data :uk-victors [_]
(->> (load-data :uk-scrubbed)
(i/$where {:Con {:$fn number?} :LD {:$fn number?}})
(i/add-derived-column :victors [:Con :LD] +)
(i/add-derived-column :victors-share [:victors :Votes] /)
(i/add-derived-column :turnout [:Votes :Electorate] /)))
结果是,我们的数据集中新增了三列::victors
、:victors-share
和 :turnout
。接下来,让我们通过 Q-Q 图展示胜选者的投票份额,看看它与理论上的正态分布有何不同:
(defn ex-1-28 []
(->> (load-data :uk-victors)
(i/$ :victors-share)
(c/qq-plot)
(i/view)))
上述代码生成了以下图表:
回顾本章前面提到的各种 Q-Q 图形,结果显示胜选者的投票份额相比正态分布具有“轻尾”特性。这意味着更多的数据点集中在均值附*,超出了我们对真正正态分布数据的预期。
选民数据的对比可视化
现在我们来看另一个大选的数据集,这次是 2011 年的俄罗斯大选。俄罗斯是一个更大的国家,其选举数据也要大得多。我们将加载两个较大的 Excel 文件到内存中,这可能会超过默认的 JVM 堆大小。
为了扩展 Incanter 可用的内存,我们可以调整项目中profile.clj
的 JVM 设置。可以通过:jvm-opts
键提供一个 JVM 的配置标志向量。这里我们使用 Java 的Xmx
标志将堆内存大小增加到 1GB,这应该足够用了。
:jvm-opts ["-Xmx1G"]
俄罗斯的数据存储在两个数据文件中。幸运的是,每个文件中的列名相同,因此它们可以按顺序连接在一起。Incanter 的i/conj-rows
函数正是为了这个目的而存在:
(defmethod load-data :ru [_]
(i/conj-rows (-> (io/resource "Russia2011_1of2.xls")
(str)
(xls/read-xls))
(-> (io/resource "Russia2011_2of2.xls")
(str)
(xls/read-xls))))
在前面的代码中,我们定义了load-data
多重方法的第三个实现来加载并合并这两个俄罗斯文件。
注意
除了conj-rows
,Incanter-core 还定义了conj-columns
,它将合并具有相同行数的数据集的列。
让我们看看俄罗斯数据的列名是什么:
(defn ex-1-29 []
(-> (load-data :ru)
(i/col-names)))
;; ["Code for district"
;; "Number of the polling district (unique to state, not overall)"
;; "Name of district" "Number of voters included in voters list"
;; "The number of ballots received by the precinct election
;; commission" ...]
俄罗斯数据集中的列名非常具有描述性,但可能比我们想要输入的更长。而且,如果与我们之前看到的英国选举数据中表示相同属性的列(例如,获胜者的份额和投票率)在两者中有相同的标签,那将更加方便。让我们相应地重命名它们。
与数据集一起,i/rename-cols
函数期望接收一个映射,其中键是当前的列名,值对应所需的新列名。如果我们将其与之前看到的i/add-derived-column
数据结合起来,我们得到如下结果:
(defmethod load-data :ru-victors [_]
(->> (load-data :ru)
(i/rename-cols
{"Number of voters included in voters list" :electorate
"Number of valid ballots" :valid-ballots
"United Russia" :victors})
(i/add-derived-column :victors-share
[:victors :valid-ballots] i/safe-div)
(i/add-derived-column :turnout
[:valid-ballots :electorate] /)))
i/safe-div
函数与/
相同,但它能够防止除以零的情况。它不会抛出异常,而是返回Infinity
,该值将在 Incanter 的统计和图表功能中被忽略。
可视化俄罗斯选举数据
我们之前看到,英国选举投票率的直方图大致呈正态分布(尽管尾部较轻)。现在我们已经加载并转换了俄罗斯选举数据,让我们看看它的对比情况:
(defn ex-1-30 []
(-> (i/$ :turnout (load-data :ru-victors))
(c/histogram :x-label "Russia turnout"
:nbins 20)
(i/view)))
上面的例子生成了以下的直方图:
这个直方图看起来根本不像我们之前看到的经典钟形曲线。它有明显的正偏态,选民的投票率实际上从 80%增加到 100%——这与我们对正态分布数据的预期正好相反。
根据英国数据和中心极限定理所设定的预期,这是一个有趣的结果。让我们改用 Q-Q 图来可视化数据:
(defn ex-1-31 []
(->> (load-data :ru-victors)
(i/$ :turnout)
(c/qq-plot)
(i/view)))
这将返回以下图表:
这个 Q-Q 图既不是一条直线,也不是特别 S 形的曲线。实际上,Q-Q 图暗示了分布的上端有轻微的尾部,而下端则有较重的尾部。这几乎与我们在直方图中看到的情况相反,后者明显表明右尾极重。
实际上,正是因为尾部如此沉重,Q-Q 图才会产生误导:直方图上 0.5 到 1.0 之间的点密度暗示峰值应在 0.7 左右,右尾则延续到 1.0 以外。显然,百分比超过 100%是没有逻辑的,但 Q-Q 图没有考虑到这一点(它并不知道我们在绘制百分比),因此 1.0 以上数据的突然缺失被解释为被截断的右尾。
鉴于中心极限定理以及我们在英国选举数据中观察到的情况,100%的选民投票率这一趋势颇为引人注目。让我们将英国和俄罗斯的数据集并排比较。
比较可视化
假设我们想比较英国和俄罗斯选民数据的分布。我们已经在本章中学习了如何使用 CDF 和箱线图,所以让我们来研究一种类似于直方图的替代方法。
我们可以尝试在直方图上绘制这两个数据集,但这不是一个好主意。我们无法解释结果,原因有二:
-
投票区的大小,以及因此而导致的分布均值,差异非常大。
-
投票区的数量差异如此之大,因此直方图的条形高度会不同
解决上述问题的一个替代方法是概率质量函数(PMF)。
概率质量函数
概率质量函数(PMF)与直方图有很多相似之处。不过,它不是绘制落入区间的数值计数,而是绘制从分布中抽取的数字恰好等于某一给定值的概率。由于该函数为分布中所有可能返回的值分配了概率,而且概率是在零到一的范围内度量的(其中一对应确定性),因此概率质量函数下的面积等于一。
因此,PMF 确保了我们绘制的图形下的面积在不同数据集之间是可比较的。然而,我们仍然面临投票区大小——因此分布的均值——无法直接比较的问题。这可以通过一个独立的技术——规范化来解决。
注意
数据规范化与正态分布无关。它是一个通用任务,用来将一个或多个数值序列对齐。根据具体情况,它可以仅仅意味着调整值使其落在相同的范围内,或者采取更复杂的程序来确保数据分布一致。通常,规范化的目的是为了便于比较两组或更多组数据。
规范化数据的方法有无数种,但最基本的一种是确保每个系列的数值都在零到一之间。我们所有的值都不会低于零,因此我们可以通过简单地除以最大值来实现这种规范化:
(defn as-pmf [bins]
(let [histogram (frequencies bins)
total (reduce + (vals histogram))]
(->> histogram
(map (fn [[k v]]
[k (/ v total)]))
(into {}))))
使用上述函数,我们可以将英国和俄罗斯的数据进行归一化,并将它们并排绘制在相同的坐标轴上:
(defn ex-1-32 []
(let [n-bins 40
uk (->> (load-data :uk-victors)
(i/$ :turnout)
(bin n-bins)
(as-pmf))
ru (->> (load-data :ru-victors)
(i/$ :turnout)
(bin n-bins)
(as-pmf))]
(-> (c/xy-plot (keys uk) (vals uk)
:series-label "UK"
:legend true
:x-label "Turnout Bins"
:y-label "Probability")
(c/add-lines (keys ru) (vals ru)
:series-label "Russia")
(i/view))))
上述例子生成了以下图表:
经过归一化处理后,这两个分布可以更方便地进行比较。显然,尽管俄罗斯的投票率均值低于英国,但俄罗斯选举的投票率在接* 100%的地方出现了大幅跃升。由于选举结果代表了许多独立选择的综合效应,我们预计选举结果会符合中心极限定理,呈大致正态分布。实际上,全球范围内的选举结果通常都符合这一预期。
尽管并不像分布中心的模态峰值那样高——对应大约 50%的投票率——但俄罗斯选举数据呈现出一个非常反常的结果。维也纳医科大学的研究员彼得·克里梅克及其同事甚至建议这明显是选票操控的标志。
散点图
我们已经观察到俄罗斯选举投票率的奇异结果,并且确认它与英国选举的签名不同。接下来,让我们看看获胜候选人的选票比例与投票率之间的关系。毕竟,如果出乎意料的高投票率真的是现任政府操纵选举的信号,那么我们预计他们会为自己投票,而不是为其他候选人投票。因此,我们预计大多数(如果不是全部的话)额外的选票将投给最终的选举获胜者。
第三章,相关性,将更详细地讨论相关两个变量的统计学原理,但现在,仅仅可视化投票率与获胜党派选票比例之间的关系就已经很有趣了。
本章我们将介绍的最后一个可视化图表是散点图。散点图非常适合用来可视化两个变量之间的相关性:如果存在线性相关性,它将在散点图中表现为对角线趋势。Incanter 包含了c/scatter-plot
函数用于这种类型的图表,参数与c/xy-plot
函数相同。
(defn ex-1-33 []
(let [data (load-data :uk-victors)]
(-> (c/scatter-plot (i/$ :turnout data)
(i/$ :victors-share data)
:x-label "Turnout"
:y-label "Victor's Share")
(i/view))))
上述代码生成了以下图表:
尽管这些点大致呈现为一个模糊的椭圆形,但在散点图中,明显存在向右上方的对角线趋势。这表明了一个有趣的结果——投票率与最终选举获胜者的选票比例之间存在关联。我们本可能预期到相反的结果:选民自满导致投票率降低,而在有明确胜者的情况下尤为如此。
注意
如前所述,2010 年英国大选远非普通选举,结果是悬浮议会和联合政府。事实上,所谓的“赢家”是指两党,这两党直到选举日之前一直是对手。选任何一方的票都算作是投给赢家的票。
接下来,我们将为俄罗斯选举创建相同的散点图:
(defn ex-1-34 []
(let [data (load-data :ru-victors)]
(-> (c/scatter-plot (i/$ :turnout data)
(i/$ :victors-share data)
:x-label "Turnout"
:y-label "Victor's Share")
(i/view))))
这将生成以下图表:
尽管俄罗斯数据中的对角趋势从点的轮廓中清晰可见,但大量数据掩盖了其内部结构。在本章的最后一部分,我们将展示一种简单的技术,利用透明度从这样的图表中提取结构。
散点透明度
在前面的情境中,当散点图被大量数据点淹没时,透明度可以帮助更好地可视化数据的结构。由于重叠的半透明点会变得更不透明,而点较少的区域会更透明,使用半透明点的散点图比使用实心点更能有效显示数据的密度。
我们可以使用c/set-alpha
函数设置 Incanter 图表上绘制点的 alpha 透明度。它接受两个参数:一个图表和一个介于零到一之间的数字。1 表示完全不透明,0 表示完全透明。
(defn ex-1-35 []
(let [data (-> (load-data :ru-victors)
(s/sample :size 10000))]
(-> (c/scatter-plot (i/$ :turnout data)
(i/$ :victors-share data)
:x-label "Turnout"
:y-label "Victor Share")
(c/set-alpha 0.05)
(i/view))))
前面的例子生成了以下图表:
前面的散点图展示了胜者的份额与选民投票率之间通常同时变化的趋势。我们可以看到这两个值之间存在一定的相关性,并且在图表的右上角有一个“热点”,它对应着接* 100%的选民投票率和赢得选举的党派几乎拿到 100%的选票。特别是,这正是维也纳医科大学的研究人员所指出的选举舞弊的标志。这一点在世界其他地方的有争议选举结果中也十分明显,比如 2011 年乌干达总统选举的结果。
提示
世界其他地方许多选举的地区级结果可以在www.complex-systems.meduniwien.ac.at/elections/election.html
上查看。访问该网站可以获取研究论文的链接,并下载其他数据集,帮助你实践本章关于清理和转换真实数据的知识。
我们将在第三章中更详细地讲解相关性,相关性,届时我们将学习如何量化两个值之间关系的强度,并基于此建立预测模型。我们还将在第十章,可视化中回顾这些数据,当时我们将实现一个自定义的二维直方图,以更清晰地可视化选民投票率与获胜党派选票比例之间的关系。
总结
在本章中,我们学习了总结性统计和分布的价值。我们已经看到,即使是简单的分析,也能提供潜在欺诈活动的证据。
尤其是,我们遇到了中心极限定理,并理解了它为何如此有助于解释正态分布在数据科学中的普遍性。一个合适的分布可以用少数几个统计量来代表一大串数字的本质,我们在本章中已经使用纯 Clojure 函数实现了其中的几个。我们还介绍了 Incanter 库,并用它加载、转换和可视化地比较了几个数据集。然而,我们并未做更多的工作,只能注意到两个分布之间一个有趣的差异。
在下一章中,我们将扩展关于描述性统计的知识,涵盖推断统计。这将使我们能够量化两个或更多分布之间的测量差异,并判断这种差异是否具有统计显著性。我们还将学习假设检验——一种进行稳健实验的框架,使我们能够从数据中得出结论。
第二章 推断
“我什么也看不见,”我说着,把它递还给我的朋友。**“相反,沃森,你什么都看得见。你只是没有从你所见的事物中推理出来。然而,你在得出结论时过于胆怯。” | ||
---|---|---|
--阿瑟·柯南·道尔,《蓝宝石冒险》 |
在上一章中,我们介绍了多种数值和视觉方法来理解正态分布。我们讨论了描述性统计量,例如均值和标准差,以及它们如何用于简洁地总结大量数据。
数据集通常是某个更大总体的样本。有时,这个总体过于庞大,无法完全测量。有时,它本质上是无法测量的,可能是因为它的大小是无限的,或因为其他原因无法直接访问。无论是哪种情况,我们都不得不从已有的数据中进行概括。
在本章中,我们将讨论统计推断:如何超越仅仅描述数据样本,而是描述它们来自的总体。我们将详细探讨我们对从数据样本中得出的推断的置信度。我们还将讨论假设检验:一种强健的数据分析方法,它将科学带入数据科学。我们还将使用 ClojureScript 实现一个交互式网页,以模拟样本与它们所来自总体之间的关系。
为了帮助说明这些原理,我们将虚构一个公司——AcmeContent,假设它最*聘请我们作为数据科学家。
介绍 AcmeContent
为了帮助说明本章的概念,假设我们最*被聘为 AcmeContent 公司的数据科学家。该公司运营着一个网站,让访问者分享他们在网上喜欢的视频片段。
AcmeContent 通过其网站分析跟踪的一个指标是停留时间。这是衡量访问者在网站上停留多久的指标。显然,花费较长时间在网站上的访问者通常是在享受网站的内容,AcmeContent 希望访问者尽可能长时间停留。如果*均停留时间增加,我们的首席执行官将非常高兴。
注意
停留时间是指访问者第一次到达网站和他们做出最后一次请求之间的时间长度。
跳出率是指只做出一次请求的访问者——他们的停留时间为零。
作为公司的新数据科学家,我们的任务是分析网站分析报告中的停留时间,并衡量 AcmeContent 网站的成功程度。
下载示例代码
本章的代码可以在github.com/clojuredatascience/ch2-inference
上找到,也可以从 Packt Publishing 的网站获取。
这个示例数据是专门为本章生成的。它足够小,因此已与书中的示例代码一起包含在数据目录中。请查阅本书的 wiki:wiki.clojuredatascience.com
以获取关于停留时间分析的进一步阅读链接。
加载并检查数据
在上一章中,我们使用 Incanter 的incanter.excel/load-xls
函数加载了 Excel 电子表格。在本章中,我们将从一个以制表符分隔的文本文件中加载数据集。为此,我们将使用incanter.io/read-dataset
,它期望接收一个 URL 对象或一个表示文件路径的字符串。
该文件已由 AcmeContent 的网页团队进行了有益的重新格式化,包含了两列——请求日期和停留时间(单位:秒)。第一行是列标题,因此我们向read-dataset
传递:header true
:
(defn load-data [file]
(-> (io/resource file)
(iio/read-dataset :header true :delim \tab)))
(defn ex-2-1 []
(-> (load-data "dwell-times.tsv")
(i/view)))
如果你运行这段代码(无论是在 REPL 中还是通过命令行使用lein run –e 2.1
),你应该会看到类似如下的输出:
让我们看看停留时间以直方图的形式呈现出来是什么样的。
可视化停留时间
我们可以通过简单地使用i/$
提取:dwell-time
列来绘制停留时间的直方图:
(defn ex-2-2 []
(-> (i/$ :dwell-time (load-data "dwell-times.tsv"))
(c/histogram :x-label "Dwell time (s)"
:nbins 50)
(i/view)))
之前的代码生成了以下的直方图:
显然,这不是一个正态分布的数据,甚至也不是一个非常偏斜的正态分布。峰值左侧没有尾部(访客显然不可能在我们的网站停留不到零秒)。虽然数据开始时右侧急剧下降,但它沿着x轴延伸得比我们从正态分布数据中预期的要远得多。
当遇到像这样的分布时,其中大部分值都很小,但偶尔出现极端值,使用对数尺度绘制y轴可能会很有用。对数尺度用于表示覆盖非常大范围的事件。通常,图表的坐标轴是线性的,它们将一个范围分割成相等大小的步骤,就像我们在学校学过的“数字线”。对数尺度则将范围分割成随着离原点越来越远而逐渐增大的步骤。
一些测量自然现象的系统,涵盖了非常大的范围,通常会使用对数尺度表示。例如,地震的里氏震级就是一个以 10 为底的对数尺度,这意味着震级为 5 的地震是震级为 4 的地震的 10 倍。分贝尺度也是一个对数尺度,但它有不同的底数——30 分贝的声波的强度是 20 分贝声波的 10 倍。在每种情况下,原理都是一样的——使用对数尺度可以将一个非常大的值范围压缩到一个更小的范围内。
在 Incanter 中,通过c/set-axis
将我们的y轴绘制为log-axis
非常简单:
(defn ex-2-3 []
(-> (i/$ :dwell-time (load-data "dwell-times.tsv"))
(c/histogram :x-label "Dwell time (s)"
:nbins 20)
(c/set-axis :y (c/log-axis :label "Log Frequency"))
(i/view)))
默认情况下,Incanter 将使用以 10 为底的对数尺度,这意味着轴上的每一个刻度代表的范围是前一步的 10 倍。像这样的图表——只有一个轴是对数尺度——称为对数-线性图。毫不奇怪,显示两个对数轴的图表称为对数-对数图。
在对数-线性图上绘制停留时间能够显示数据中的隐藏一致性——停留时间与频率的对数之间存在线性关系。除了图表右侧(那里的访问者少于 10 个)出现关系不再清晰外,其他部分该关系非常一致。
在对数-线性图上,直线是指数分布的明显指示。
指数分布
指数分布通常出现在考虑有许多小的正量和较少的较大量的情况时。根据我们对里氏震级的了解,地震的震级遵循指数分布这一点应该不会令人惊讶。
该分布也经常出现在等待时间中——直到下一次任何震级的地震发生的时间大致也遵循指数分布。该分布常用于建模故障率,本质上是指机器故障的等待时间。我们的指数分布模型类似于故障过程——即访问者厌倦并离开我们网站的等待时间。
指数分布具有许多有趣的性质。其中之一与均值和标准差有关:
(defn ex-2-4 []
(let [dwell-times (->> (load-data "dwell-times.tsv")
(i/$ :dwell-time))]
(println "Mean: " (s/mean dwell-times))
(println "Median:" (s/median dwell-times))
(println "SD: " (s/sd dwell-times))))
Mean: 93.2014074074074
Median: 64.0
SD: 93.96972402519796
均值和标准差非常相似。实际上,对于理想的指数分布,它们是完全相同的。这个特性适用于所有的指数分布——均值越大,标准差也越大。
注意
对于指数分布,均值和标准差相等。
指数分布的第二个特性是它是无记忆的。这是一个与直觉相反的特性,最好的说明方法是通过一个例子来展示。我们通常认为,随着访问者继续浏览我们的网站,他们厌倦并离开的概率会增加。由于*均停留时间为 93 秒,因此可能会出现这样的想法:超过 93 秒后,他们继续浏览的可能性会越来越小。
指数分布的无记忆特性告诉我们,访问者在我们的网站上停留额外 93 秒的概率,与他们已经浏览了 93 秒、5 分钟、1 小时,还是刚刚到达网站时的浏览时间无关。
注意
对于无记忆分布,继续再等额外的x分钟的概率与已经经过的时间无关。
指数分布的记忆无关特性在一定程度上解释了为何如此难以预测地震何时发生。我们必须依赖其他证据(例如地磁扰动),而非时间的流逝。
由于中位数停留时间为 64 秒,大约一半的访客只在网站停留约一分钟。*均值 93 秒表明,有些访客停留的时间要长得多。这些统计数据是基于过去 6 个月所有访客计算的。可能有趣的是,看看这些统计数据在每天的变化。让我们现在计算一下。
日均值分布
网络团队提供的文件包含了访问的时间戳。为了按天汇总数据,我们需要从日期中移除时间部分。虽然我们可以通过字符串操作来完成这一任务,但更灵活的方法是使用日期和时间库,如clj-time
(github.com/clj-time/clj-time
)来解析字符串。这将不仅使我们能够移除时间,还能执行任意复杂的过滤操作(例如,筛选特定的星期几,或每月的第一天或最后一天)。
clj-time.predicates
命名空间包含了多种有用的谓词,而 clj-time.format
命名空间包含了尝试使用预定义的标准格式将字符串转换为日期时间对象的解析函数。如果我们的时间戳不是标准格式,我们可以使用相同的命名空间构建自定义格式化器。更多信息和使用示例,请参考 clj-time
文档:
(defn with-parsed-date [data]
(i/transform-col data :date (comp tc/to-local-date f/parse)))
(defn filter-weekdays [data]
(i/$where {:date {:$fn p/weekday?}} data))
(defn mean-dwell-times-by-date [data]
(i/$rollup :mean :dwell-time :date data))
(defn daily-mean-dwell-times [data]
(->> (with-parsed-date data)
(filter-weekdays)
(mean-dwell-times-by-date)))
将前面的函数结合起来,我们可以计算日均停留时间的*均值、中位数和标准差:
(defn ex-2-5 []
(let [means (->> (load-data "dwell-times.tsv")
(daily-mean-dwell-times)
(i/$ :dwell-time))]
(println "Mean: " (s/mean means))
(println "Median: " (s/median means))
(println "SD: " (s/sd means))))
;; Mean: 90.210428650562
;; Median: 90.13661202185791
;; SD: 3.722342905320035
我们的日均值的*均值是 90.2 秒。这个值接*我们之前计算的整个数据集(包括周末)的*均值。然而,标准差要低得多,仅为 3.7 秒。换句话说,日均值的分布比整个数据集的标准差要小得多。接下来,让我们在图表上绘制日均停留时间:
(defn ex-2-6 []
(let [means (->> (load-data "dwell-times.tsv")
(daily-mean-dwell-times)
(i/$ :dwell-time))]
(-> (c/histogram means
:x-label "Daily mean dwell time (s)"
:nbins 20)
(i/view))))
这段代码生成了以下的直方图:
样本均值的分布围绕整体总均值 90 秒对称,标准差为 3.7 秒。与这些均值抽样的分布——指数分布不同,样本均值的分布呈正态分布。
中心极限定理
我们在上一章中遇到了中心极限定理,当时我们从均匀分布中取样并对其求*均。实际上,中心极限定理适用于任何值的分布,只要该分布的标准差是有限的。
注意
中心极限定理指出,不管样本是从哪种分布中计算出来的,样本均值的分布将是正态分布。
无论基础分布是否为指数分布,都不重要——中心极限定理表明,从任何分布中随机抽取的样本的均值将接*正态分布。让我们在直方图上绘制正态曲线,看看它与实际数据的匹配程度。
为了在我们的直方图上绘制正态曲线,我们必须将直方图绘制为密度直方图。这样绘制的是每个桶中所有数据点的比例,而不是频率。然后,我们可以叠加具有相同均值和标准差的正态概率密度:
(defn ex-2-7 []
(let [means (->> (load-data "dwell-times.tsv")
(daily-mean-dwell-times)
(i/$ :dwell-time))
mean (s/mean means)
sd (s/sd means)
pdf (fn [x]
(s/pdf-normal x :mean mean :sd sd))]
(-> (c/histogram means
:x-label "Daily mean dwell time (s)"
:nbins 20
:density true)
(c/add-function pdf 80 100)
(i/view))))
该代码生成了以下图表:
绘制在直方图上的正态曲线的标准差大约是 3.7 秒。换句话说,这量化了每天均值相对于 90 秒总体均值的变异。我们可以将每天的均值看作是来自总体的样本,而前面提到的曲线表示的是样本均值的分布。因为 3.7 秒是样本均值与总体均值的差异,所以它被称为标准误差。
标准误差
标准差衡量样本内部的变异量,而标准误差衡量从同一总体中抽取样本的均值之间的变异量。
注意
标准误差是样本均值分布的标准差。
我们通过查看过去 6 个月的数据经验性地计算了停留时间的标准误差。但也有一个方程式,允许我们仅通过一个样本来计算它:
这里,σ[x] 是标准差,n 是样本大小。这与我们在上一章中学习的描述性统计不同。描述性统计描述的是单个样本,而标准误差试图描述样本的一般特性——即样本均值的变异量,可以预期给定大小的样本会有变异:
(defn standard-deviation [xs]
(Math/sqrt (variance xs)))
(defn standard-error [xs]
(/ (standard-deviation xs)
(Math/sqrt (count xs))))
均值的标准误差与两个因素有关:
-
样本大小
-
总体标准差
样本大小对标准误差的影响最大。由于我们需要对样本大小取*方根,因此必须将样本大小增加四倍才能使标准误差减半。
可能会让人感到奇怪的是,总体样本的比例对标准误差的大小没有影响。这其实是好事,因为某些总体的大小可能是无限的。
样本与总体
"样本"和"总体"这两个词对统计学家来说有着非常特殊的含义。总体是研究者希望理解或从中得出结论的所有实体的集合。例如,在 19 世纪下半叶,遗传学的奠基人格雷戈尔·约翰·孟德尔记录了豌豆植物的观察数据。尽管他是在实验室中研究特定的植物,但他的目标是理解所有可能的豌豆植物中的遗传机制。
注意
统计学家将从中抽取样本的实体群体称为总体,无论被研究的对象是否为人类。
由于总体可能非常大——或像孟德尔研究的豌豆植物那样是无限的——我们必须研究具有代表性的样本,并从中推断总体。为了区分样本的可测量属性和总体的不可得属性,我们使用"统计量"一词来指代样本属性,使用"参数"一词来指代总体属性。
注意
统计量是我们可以从样本中测量的属性。参数是我们试图推断的总体属性。
事实上,统计量和参数通过在数学公式中使用不同的符号来区分:
测量 | 样本统计量 | 总体参数 |
---|---|---|
项目数量 | n | N |
均值 | ![]() |
µ[x] |
标准差 | S[x] | σ[x] |
标准误差 | ![]() |
这里, 发音为"x-bar",µ[x] 发音为"mu x",σ[x] 发音为"sigma x"。
如果你回顾标准误差的公式,你会注意到它是从总体标准差σ[x]而不是样本标准差S[x]中计算得出的。这给我们带来了一个悖论——我们无法在总体参数正是我们试图推断的值时,使用总体参数来计算样本统计量。然而,实际上,当样本量大约在 30 以上时,样本标准差和总体标准差通常假设是相同的。
让我们从某一天的均值来计算标准误差。例如,假设我们选择某一天,比如 5 月 1 日:
(defn ex-2-8 []
(let [may-1 (f/parse-local-date "2015-05-01")]
(->> (load-data "dwell-times.tsv")
(with-parsed-date)
(filtered-times {:date {:$eq may-1}})
(standard-error))))
;; 3.627
尽管我们只从一天的数据中抽取了一个样本,但我们计算出的标准误差与所有样本均值的标准差非常接*——3.6 与 3.7。就像一个包含 DNA 的细胞一样,每个样本都编码了关于整个总体的信息。
置信区间
由于我们的样本的标准误差衡量了我们预期样本均值与总体均值之间的匹配程度,我们也可以考虑其逆向——标准误差衡量了我们预期总体均值与我们测得的样本均值之间的匹配程度。换句话说,基于我们的标准误差,我们可以推断总体均值在某个预期的样本均值范围内,并且具有一定的置信度。
总的来说,“置信度”和“预期范围”共同定义了置信区间。在陈述置信区间时,通常会陈述 95% 置信区间——我们有 95% 的信心认为总体参数位于该区间内。当然,仍然有 5% 的可能性它不在其中。
无论标准误差是多少,95% 的总体均值将位于样本均值的 -1.96 和 1.96 个标准差之间。因此,1.96 是 95% 置信区间的临界 z 值。
注意
z-值这个名字来源于正态分布也被称为z-分布的事实。
数字 1.96 使用得非常广泛,是一个值得记住的数字,但我们也可以使用 s/quantile-normal
函数来计算临界值。我们接下来的 confidence-interval
函数期望输入一个在零到一之间的 p
值。对于我们的 95% 置信区间,这个值为 0.95。我们需要从 1 中减去它并除以 2,以计算两个尾部的大小(95% 置信区间的 2.5%):
(defn confidence-interval [p xs]
(let [x-bar (s/mean xs)
se (standard-error xs)
z-crit (s/quantile-normal (- 1 (/ (- 1 p) 2)))]
[(- x-bar (* se z-crit))
(+ x-bar (* se z-crit))]))
(defn ex-2-9 []
(let [may-1 (f/parse-local-date "2015-05-01")]
(->> (load-data "dwell-times.tsv")
(with-parsed-date)
(filtered-times {:date {:$eq may-1}})
(confidence-interval 0.95))))
;; [83.53415272762004 97.75306531749274]
结果告诉我们,我们可以有 95% 的信心认为总体均值位于 83.53 秒到 97.75 秒之间。事实上,我们之前计算的总体均值正好位于这个范围之内。
样本比较
在一次病毒式营销活动之后,AcmeContent 的网络团队从单一天的数据中为我们提供了一个停留时间的样本以供分析。他们想知道他们最新的营销活动是否吸引了更多互动的访客。置信区间为我们提供了一种直观的方式来比较这两个样本。
我们像之前一样加载来自活动的停留时间,并以相同的方式对其进行总结:
(defn ex-2-10 []
(let [times (->> (load-data "campaign-sample.tsv")
(i/$ :dwell-time))]
(println "n: " (count times))
(println "Mean: " (s/mean times))
(println "Median: " (s/median times))
(println "SD: " (s/sd times))
(println "SE: " (standard-error times))))
;; n: 300
;; Mean: 130.22
;; Median: 84.0
;; SD: 136.13370714388046
;; SE: 7.846572839994115
这个均值似乎比我们之前看到的均值要大得多——130 秒对比 90 秒。这里可能存在显著差异,尽管标准误差是我们之前一天样本的两倍多,这是由于样本量较小且标准差较大。我们可以使用和之前相同的 confidence-interval
函数基于这些数据计算总体均值的 95% 置信区间:
(defn ex-2-11 []
(->> (load-data "campaign-sample.tsv")
(i/$ :dwell-time)
(confidence-interval 0.95)))
;; [114.84099983154137 145.59900016845864]
总体均值的 95%置信区间是 114.8 秒到 145.6 秒。这与我们之前计算的 90 年代的总体均值完全不重合。看起来存在一个很大的基础群体差异,单纯通过抽样误差是不太可能产生的。现在我们的任务是找出原因。
偏差
样本应当能够代表其所抽取的总体。换句话说,它应当避免产生偏差,使得某些种类的群体成员在系统性地被排除(或包含)时,相较于其他群体而言,受到不公正的影响。
一个著名的样本偏差例子是 1936 年《文学文摘》对美国总统选举的民调。这是历史上最大、最昂贵的民调之一,共有 240 万人通过邮件接受调查。结果非常明确——堪萨斯州的共和党州长阿尔弗雷德·兰登将击败富兰克林·D·罗斯福,获得 57%的选票。然而,最终罗斯福以 62%的选票赢得了选举。
该杂志巨大的抽样误差的主要原因是样本选择偏差。在试图收集尽可能多的选民地址时,《文学文摘》翻阅了电话簿、杂志订阅名单和俱乐部会员名单。在那个电话是奢侈品的时代,这一过程必然会偏向上层和中产阶级选民,无法代表整个选民群体。偏差的次要原因是无应答偏差——不到四分之一的受访者回应了调查。这是一种选择偏差,只偏向那些真正愿意参与的受访者。
避免样本选择偏差的常见方法是确保采样在某种程度上是随机的。将随机性引入过程可以减少实验因素不公*地影响样本质量的可能性。《文学文摘》民调的重点是尽可能获得最大的样本,但一个无偏的小样本比一个错误选择的大样本更有用。
如果我们打开campaign-sample.tsv
文件,我们会发现我们的样本完全来自 2015 年 6 月 6 日。这一天是周末,我们可以通过clj-time
轻松确认这一点:
(p/weekend? (t/date-time 2015 6 6))
;; true
到目前为止,我们的汇总统计数据都是基于我们只筛选了工作日数据的结果。这是样本中的一种偏差,如果周末的访问者行为与工作日的行为有所不同——这是一种非常可能的情况——那么我们可以说,这些样本代表了两个不同的群体。
可视化不同群体
让我们去掉工作日的筛选条件,绘制工作日和周末的日均停留时间:
(defn ex-2-12 []
(let [means (->> (load-data "dwell-times.tsv")
(with-parsed-date)
(mean-dwell-times-by-date)
(i/$ :dwell-time))]
(-> (c/histogram means
:x-label "Daily mean dwell time unfiltered (s)"
:nbins 20)
(i/view))))
代码生成了以下直方图:
这时的分布不再是正态分布。事实上,分布是双峰分布——有两个峰值。第二个较小的峰值,代表新加入的周末数据,相对较低,这既是因为周末天数少于工作日,也因为分布的标准误差较大。
注意
通常,具有多个峰值的分布称为多峰分布。它们可能表示两个或更多的正态分布被合并,因此,可能表示两个或更多的人群被合并。一个经典的双峰分布的例子是人的身高分布,因为男性的常见身高大于女性的常见身高。
周末数据与工作日数据具有不同的特征。我们应确保在比较时是“对比相同的事物”。让我们将原始数据集过滤,仅保留周末数据:
(defn ex-2-13 []
(let [weekend-times (->> (load-data "dwell-times.tsv")
(with-parsed-date)
(i/$where {:date {:$fn p/weekend?}})
(i/$ :dwell-time))]
(println "n: " (count weekend-times))
(println "Mean: " (s/mean weekend-times))
(println "Median: " (s/median weekend-times))
(println "SD: " (s/sd weekend-times))
(println "SE: " (standard-error weekend-times))))
;; n: 5860
;; Mean: 117.78686006825939
;; Median: 81.0
;; SD: 120.65234077179436
;; SE: 1.5759770362547665
周末的总体均值(基于 6 个月的数据)为 117.8 秒,落在市场样本的 95%置信区间内。换句话说,虽然 130 秒是较高的均值,甚至对于周末来说,但这一差异并不大到无法仅归因于样本内的随机波动。
我们刚才采取的建立人群差异(即周末访问者与工作日访问者之间的差异)的方法,并不是统计检验常规的做法。更常见的方法是从一个理论开始,然后用数据检验这个理论。统计方法为此定义了一种严格的流程,称为假设检验。
假设检验
假设检验是统计学家和数据科学家的正式过程。假设检验的标准方法是定义一个研究领域,决定哪些变量是必要的以测量所研究的内容,然后提出两个竞争的假设。为了避免只看符合我们偏见的数据,研究人员会事先明确陈述他们的假设。然后,统计数据可以用来根据数据确认或否定这个假设。
为了帮助留住我们的访客,设计师开始修改主页,采用所有最新的技术来吸引观众的注意力。我们希望确保我们的努力不会白费,因此我们将关注新网站上的停留时间是否有所增加。
因此,我们的研究问题是“新网站是否导致访客的停留时间增加”?我们决定用均值停留时间来进行检验。现在,我们需要列出两个假设。按惯例,数据被假设不包含研究者所寻找的内容。保守的观点是,数据不会显示任何异常。这就是零假设,通常用H[0]表示。
注意
假设检验假定原假设为真,直到证据的权重使得这个假设变得不太可能。这种“倒过来看”证据的方法部分是由一个简单的心理事实驱动的:当人们去寻找某样东西时,他们倾向于找到它。
研究者随后形成一个备择假设,表示为H[1]。这可能仅仅意味着总体均值与基准值不同。或者,它可能意味着总体均值大于或小于基准值,甚至可能大于或小于某个特定值。我们希望测试新网站是否能增加停留时间,因此这些将是我们的原假设和备择假设:
-
H[0]:新网站的停留时间与现有网站的停留时间没有区别。
-
H[1]:新网站的停留时间相比于现有网站更长。
我们的保守假设是新网站对用户停留时间没有影响。原假设不一定是“无效假设”(即没有效应),但在这种情况下,我们没有合理的理由假设它有所不同。如果样本数据不支持原假设(如果数据与其预测的差异过大,不可能仅由偶然造成),我们将拒绝原假设,并提出备择假设作为最好的替代解释。
在设定原假设和备择假设后,我们必须设定一个显著性水*,用来衡量我们是否在寻找某种效应。
显著性
显著性检验最初是独立于假设检验开发的,但如今这两种方法常常一起使用。显著性检验的目的是设定一个阈值,超过该阈值我们认为观察到的数据不再支持原假设。
因此,存在两个风险:
-
我们可能会接受一个差异是显著的,而实际上它可能是偶然产生的。
-
我们可能会将一个差异归因于偶然,而实际上它代表了一个真正的总体差异。
这两种可能性分别被称为第一类错误和第二类错误:
H[0] 错误 | H[0] 正确 | |
---|---|---|
拒绝 H[0] | 真阴性 | 第一类错误 |
接受 H[0] | 第二类错误 | 真阳性 |
我们减少第一类错误的风险时,第二类错误的风险就会增加。换句话说,我们希望避免在没有真实差异时错误地认为有差异,因此我们要求样本之间有更大的差异才能宣称统计显著性。这增加了我们忽略真正差异的可能性。
统计学家常用两个显著性阈值。这些分别是 5%的显著性水*和 1%的显著性水*。5%的差异通常被称为显著,而 1%的差异被称为高度显著。阈值的选择通常用希腊字母α(α)在公式中表示。由于找不到效果可能被认为是失败(无论是实验失败还是新网站失败),我们可能会倾向于调整α,直到我们发现效果为止。因此,教科书中的显著性检验方法要求我们在查看数据之前就设定显著性水*。5%的水*通常是默认选择的,所以我们也选择这个水*。
测试新网站设计
AcmeContent 的网络团队一直在努力开发一个新的网站,目的是鼓励访客停留更长时间。他们采用了所有最新的技术,因此我们非常有信心这个网站能够显著提高停留时间。
AcmeContent 并不打算一次性将其推出给所有用户,而是希望先在一小部分访客中进行测试。我们已经向他们讲解了样本偏差,因此网络团队将网站流量的 5%随机导向新网站,持续一天。结果以单个文本文件的形式提供给我们,文件中包含当天所有的流量数据。每行显示的是一个访客的停留时间,并给出一个值,"0"表示他们使用了原始网站设计,"1"表示他们看到了新的(并且希望能改进的)网站。
进行 z 检验
在之前使用置信区间进行测试时,我们有一个单一的总体均值来进行比较。
通过z检验,我们可以选择比较两个样本。观看新网站的人是随机分配的,且两个组的数据是在同一天收集的,以排除其他时间相关因素的干扰。
由于我们有两个样本,因此我们也有两个标准误差。z 检验是基于合并标准误差进行的,合并标准误差只是将方差的和除以样本大小后再开方。这与我们将样本合并后的标准误差相同:
在这里,是样本a的方差,
是样本b的方差。n[a]和n[b]分别是样本a和b的样本大小。合并标准误差可以像这样在 Clojure 中计算:
(defn pooled-standard-error [a b]
(i/sqrt (+ (/ (i/sq (standard-deviation a)) (count a))
(/ (i/sq (standard-deviation b)) (count b)))))
为了确定我们所看到的差异是否异常大,我们可以计算观测到的均值差异与合并标准误差的比值。这个值被赋予变量名z:
使用我们的pooled-standard-error
函数,可以像这样计算z统计量:
(defn z-stat [a b]
(-> (- (mean a)
(mean b))
(/ (pooled-standard-error a b))))
z比率反映了均值相差的大小,相对于标准误差的期望量。因此,z-统计量告诉我们均值之间相差多少个标准误差。由于标准误差服从正态分布,我们可以通过查找标准正态累积分布(CDF)来将这个差异与概率关联起来:
(defn z-test [a b]
(s/cdf-normal (z-stat a b)))
以下示例使用z-检验来比较两个网站的性能。我们通过按网站分组行,将网站索引到网站行集合的映射。我们调用map-vals
与(partial map :dwell-time)
一起,将行集合转换为停留时间集合。map-vals
是 Medley(github.com/weavejester/medley
)库中定义的一个轻量级工具函数:
(defn ex-2-14 []
(let [data (->> (load-data "new-site.tsv")
(:rows)
(group-by :site)
(map-vals (partial map :dwell-time)))
a (get data 0)
b (get data 1)]
(println "a n:" (count a))
(println "b n:" (count b))
(println "z-stat: " (z-stat a b))
(println "p-value:" (z-test a b))))
;; a n: 284
;; b n: 16
;; z-stat: -1.6467438180091214
;; p-value: 0.049805356789022426
设置 5%的显著性水*就像设置 95%的置信区间本质上是一样的。实质上,我们在查看观察到的差异是否落在 95%的置信区间之外。如果是这样,我们可以声称找到了一个在 5%的显著性水*上显著的结果。
注意
p-值是指在原假设真实的情况下,错误地拒绝原假设的概率。p-值越小,我们越能确信原假设是错误的,并且我们发现了一个真正的效应。
这段代码返回的值为 0.0498,等于 4.98%。因为这个值略低于我们的显著性阈值 5%,所以我们可以声称我们发现了显著的结果。
让我们回顾一下原假设和备择假设:
-
H[0]: 新网站的停留时间与现有网站的停留时间没有差异。
-
H[1]: 新网站的停留时间比现有网站的停留时间更长。
我们的备择假设是新网站的停留时间更长。
我们准备声称统计显著性,并且新网站的停留时间比现有网站的停留时间更长,但我们遇到一个问题——样本量较小时,样本标准差与总体标准差匹配的不确定性增加。我们的新网站样本只有 16 位访问者,如前面例子中的输出所示。像这样的样本量会使得标准误差服从正态分布的假设无效。
幸运的是,有一种统计检验和相应的分布可以模型化样本量较小时标准误差的不确定性增大。
斯图登特的 t 分布
t-分布由威廉·西利·戈塞特(William Sealy Gossett)推广,他是爱尔兰吉尼斯啤酒厂的化学家,他将其应用于他的 Stout 分析中。
注意
威廉·戈塞特在 1908 年在《Biometrika》上发表了这个检验,但由于他的雇主认为他们使用统计学是商业机密,戈塞特被迫使用笔名。他选择的笔名是“学生(Student)”。
虽然正态分布完全由两个参数——均值和标准差描述,但t分布只由一个参数描述,即自由度。自由度越大,t分布越接*均值为零、标准差为一的正态分布。当自由度减小时,分布变得更宽,尾部比正态分布更胖。
前面的图表显示了t分布相对于正态分布在不同自由度下的变化。较小样本量对应着更胖的尾部,这意味着观察到大偏差的概率更高。
自由度
自由度,通常缩写为df,与样本大小密切相关。它是一个有用的统计量,也是一个直观的系列属性,可以通过简单的例子进行演示。
如果你被告知两个值的均值为 10 且其中一个值为 8,你不需要任何额外的信息就能推断出另一个值是 12。换句话说,对于样本量为 2 且均值已知的情况,一旦知道其中一个值,另一个值就有约束。
如果你被告知三个值的均值为 10 且第一个值也是 10,你就无法推断出剩余两个值是什么。因为有无限多个以 10 为起始值且均值为 10 的三个数字集合,在你能推断出第三个值之前,必须先指定第二个值。
对于任何三个数字的集合,约束是简单的:你可以自由选择前两个数字,但最后一个数字是有约束的。自由度可以通过以下方式概括:对于任何单一样本,自由度为样本大小减一。
在比较两个数据样本时,自由度是样本大小总和减去 2,即等于它们各自自由度的总和。
t 统计量
在使用t分布时,我们查找t统计量。与z统计量类似,该值量化了某一特定观察偏差的不太可能性。对于双样本t检验,t 统计量的计算方式如下:
这里,是合并标准误差。我们可以像之前一样计算合并标准误差:
然而,该方程假设已知总体参数σ[a]和σ[b],这些只能通过大样本来*似。t检验是为小样本设计的,因此不需要我们对总体方差做出假设。
因此,对于t检验,我们将合并的标准误差写为标准误差的*方和的*方根:
实际上,前面提到的两条关于合并标准误差的公式在给定相同输入数据的情况下会得到相同的结果。符号的不同仅仅是为了说明在t检验中,我们只依赖样本统计量作为输入。合并标准误差可以通过以下方式计算:
(defn pooled-standard-error [a b]
(i/sqrt (+ (i/sq (standard-error a))
(i/sq (standard-error b)))))
尽管在数学符号上有所不同,但实际上,计算t-统计量与计算z-统计量是相同的:
(def t-stat z-stat)
(defn ex-2-15 []
(let [data (->> (load-data "new-site.tsv")
(:rows)
(group-by :site)
(map-vals (partial map :dwell-time)))
a (get data 0)
b (get data 1)]
(t-stat a b)))
;; -1.647
这两种统计量之间的差异是概念性的,而非算法性的——z-统计量仅适用于样本遵循正态分布的情况。
执行 t 检验
t-检验的工作方式的不同,源自用于计算p值的概率分布。计算了我们的t-统计量后,我们需要根据数据的自由度查找t-分布中的值:
(defn t-test [a b]
(let [df (+ (count a) (count b) -2)]
(- 1 (s/cdf-t (i/abs (t-stat a b)) :df df))))
自由度是样本总量减去二,即我们样本的自由度是 298。
回想一下,我们正在进行假设检验。因此,首先让我们陈述零假设和备择假设:
-
H[0]:此样本来自具有给定均值的总体
-
H[1]:此样本来自具有更大均值的总体
让我们运行这个示例:
(defn ex-2-16 []
(let [data (->> (load-data "new-site.tsv")
(:rows)
(group-by :site)
(map-vals (partial map :dwell-time)))
a (get data 0)
b (get data 1)]
(t-test a b)))
;; 0.0503
这返回一个超过 0.05 的p值。由于这个值大于我们为假设检验设置的 5% α,我们无法拒绝零假设。我们对于均值差异的检验没有通过t检验发现显著差异。因此,z-检验所得到的微弱显著结果部分是由于样本量太小。
双尾检验
在我们的备择假设中,隐含假设新站点的表现会优于旧站点。假设检验的过程会尽可能避免在寻找统计显著性时暗中引入隐性假设。
只关注数量显著增加或减少的检验被称为单尾检验,通常是不被推荐的,除非发生相反方向的变化是不可行的。这个名字来源于单尾检验将所有的α分配到分布的一个尾部。通过不检验另一方向,该检验可以更有力地拒绝零假设,并在本质上降低判断结果显著性的门槛。
注意
统计功效是正确接受备择假设的概率。这可以被认为是检验发现效应的能力,在效应存在的情况下。
尽管更高的统计功效听起来是理想的,但它也意味着发生第一类错误的概率更大。一个更正确的方法是考虑新站点可能比现有站点差的可能性。这将我们的α值*分到分布的两个尾部,并确保结果的显著性不受先前假设改进的偏倚。
事实上,Incanter 已经提供了执行双样本t检验的函数,即s/t-test
函数。我们将数据样本作为第一个参数传递,另一个样本则通过:y
关键字传递给函数进行比较。Incanter 会假设我们要进行双尾检验,除非我们传递:alternative
关键字并指定:greater
或:lower
,此时将进行单尾检验。
(defn ex-2-17 []
(let [data (->> (load-data "new-site.tsv")
(:rows)
(group-by :site)
(map-vals (partial map :dwell-time)))
a (get data 0)
b (get data 1)]
(clojure.pprint/print (s/t-test a :y b))))
;; {:p-value 0.12756432502462456,
;; :df 17.7613823496861,
;; :n2 16,
;; :x-mean 87.95070422535211,
;; :y-mean 122.0,
;; :x-var 10463.941024237305,
;; :conf-int [-78.9894629402365 10.890871390940724],
;; :y-var 6669.866666666667,
;; :t-stat -1.5985205593851322,
;; :n1 284}
Incanter 的t检验返回了大量信息,包括p-值。这个p-值大约是我们为单尾检验计算值的两倍。事实上,它不是恰好是两倍,唯一的原因是 Incanter 实现了一种稍有变异的t检验,叫做Welch's t-test,当两个样本的标准差不同时,这种检验略微更强健。由于我们知道对于指数分布,均值和方差是密切相关的,因此这个检验应用起来稍微严格一些,并返回一个更低的显著性水*。
单样本 t 检验
独立样本的t检验是最常见的统计分析方法,它提供了一种非常灵活和通用的方式,用于比较两个样本是否代表相同或不同的总体。然而,在总体均值已知的情况下,还有一种更简单的检验,即由s/simple-t-test
表示。
我们通过:mu
关键字传递一个样本和一个总体均值进行检验。因此,如果我们仅仅想看看我们新站点的*均停留时间是否显著不同于先前 90 秒的总体均值,我们可以进行如下测试:
(defn ex-2-18 []
(let [data (->> (load-data "new-site.tsv")
(:rows)
(group-by :site)
(map-vals (partial map :dwell-time)))
b (get data 1)]
(clojure.pprint/pprint (s/t-test b :mu 90))))
;; {:p-value 0.13789520958229406,
;; :df 15,
;; :n2 nil,
;; :x-mean 122.0,
;; :y-mean nil,
;; :x-var 6669.866666666667,
;; :conf-int [78.48152745280898 165.51847254719104],
;; :y-var nil,
;; :t-stat 1.5672973291495713,
;; :n1 16}
simple-t-test
函数不仅返回检验的p-值,还会返回总体均值的置信区间。它很宽,从 78.5 秒到 165.5 秒,显然与我们测试中的 90 秒有重叠。这也解释了为什么我们无法拒绝原假设。
重采样
为了直观地理解t检验如何从如此少的数据中确认和计算这些统计量,我们可以应用一种叫做重采样的方法。重采样的前提是每个样本只是从一个总体中可能出现的无限多个样本之一。通过从现有样本中多次重新抽取样本,我们可以更好地理解这些其他样本的性质,从而更清楚地理解底层的总体。
实际上有几种重抽样技术,我们将讨论其中一种最简单的方法——自助法(bootstrapping)。在自助法中,我们通过反复从原始样本中随机抽取值并进行有放回抽样,直到生成一个与原始样本大小相同的新样本。由于每次随机选择后都会放回原始值,因此相同的源值可能会在新样本中出现多次。就好像我们在从一副扑克牌中反复抽取随机卡片,但每次抽完都会将卡片放回。偶尔,我们会再次抽到之前选过的卡片。
我们可以在 Incanter 中轻松地对样本进行自助法重抽样,利用bootstrap
函数生成多个重抽样。bootstrap
函数接受两个参数——原始样本和一个汇总统计量(该统计量将在重抽样样本上计算),以及一些可选参数——:size
(需要计算的重抽样样本数量,每个样本的大小与原始样本相同)、:smooth
(是否对离散统计量(如中位数)的输出进行*滑处理)、:smooth-sd
和:replacement
,默认为true
:
(defn ex-2-19 []
(let [data (->> (load-data "new-site.tsv")
(i/$where {:site {:$eq 1}})
(i/$ :dwell-time ))]
(-> (s/bootstrap data s/mean :size 10000)
(c/histogram :nbins 20
:x-label "Bootstrapped mean dwell times (s)")
(i/view))))
让我们用直方图来可视化输出结果:
直方图显示了随着重复(重)抽样新站点停留时间值的变化,均值的变化情况。尽管输入的数据只有一个 16 位访客的样本,但自助法重抽样清晰地模拟了原始样本的标准误差,并可视化了我们之前通过单一样本 t 检验计算出的置信区间(78 秒到 165 秒)。
通过自助法重抽样,尽管我们的输入只有一个样本,我们通过多次抽样进行了模拟。这是一种广泛有用的技术,可以估计那些我们无法或者不知道如何进行解析计算的参数。
测试多个设计
令人失望的是,我们发现新站点设计并未显著提高用户的停留时间。不过,我们在向全球推出之前,在一小部分用户中发现了这一问题,也算是幸运的。
不灰心丧气,AcmeContent 的网页团队加班加点,设计了一套替代的站点方案。通过从其他设计中汲取最佳元素,他们提出了 19 个变体供测试。加上我们的原始站点(作为对照组),一共有 20 个不同的站点来吸引访客。
计算样本均值
网页团队将 19 个新设计和原始设计一起部署。正如之前所提到的,每个设计都会随机分配 5%的访客。我们让测试运行 24 小时。
第二天,我们收到了一份文件,展示了每个站点设计的访客停留时间。每个设计都有一个编号,其中0
代表原始未更改的设计,1
到19
代表其他设计:
(defn ex-2-20 []
(->> (i/transform-col (load-data "multiple-sites.tsv")
:dwell-time float)
(i/$rollup :mean :dwell-time :site)
(i/$order :dwell-time :desc)
(i/view)))
这段代码生成了如下表格:
我们希望测试每个站点设计,以查看是否有任何生成统计学显著结果。为此,我们可以通过以下方式将各个站点进行比较:
(defn ex-2-21 []
(let [data (->> (load-data "multiple-sites.tsv")
(:rows)
(group-by :site)
(map-vals (partial map :dwell-time)))
alpha 0.05]
(doseq [[site-a times-a] data
[site-b times-b] data
:when (> site-a site-b)
:let [p-val (-> (s/t-test times-a :y times-b)
(:p-value))]]
(when (< p-val alpha)
(println site-b "and" site-a
"are significantly different:"
(format "%.3f" p-val))))))
然而,这并不是一个好主意。即使这些差异是偶然发生的,我们也很可能会看到在表现特别好的页面与表现特别差的页面之间存在统计学差异。如果你运行前面的示例,你会看到许多页面在统计学上彼此存在差异。
另外,我们可以将每个站点与我们当前的基准进行比较——即目前我们网站的*均停留时间为 90 秒:
(defn ex-2-22 []
(let [data (->> (load-data "multiple-sites.tsv")
(:rows)
(group-by :site)
(map-vals (partial map :dwell-time)))
baseline (get data 0)
alpha 0.05]
(doseq [[site-a times-a] data
:let [p-val (-> (s/t-test times-a :y baseline)
(:p-value))]]
(when (< p-val alpha)
(println site-a
"is significantly different from baseline:"
(format "%.3f" p-val))))))
这个测试确定了两个站点与基准值有显著差异:
;; 6 is significantly different from baseline: 0.007
;; 10 is significantly different from baseline: 0.006
较小的p值(小于 1%)表示存在非常显著的统计学差异。这看起来非常有前景,但我们有一个问题。我们已经对 20 个数据样本进行了t检验,设定α为 0.05。α的定义是错误拒绝原假设的概率。通过进行 20 次t检验,实际上有可能错误地拒绝至少一个页面的原假设。
通过像这样一次性比较多个页面,我们使得t检验的结果失效。解决在统计检验中进行多重比较的问题,存在多种替代技术,我们将在后面的章节中介绍这些方法。
多重比较
事实上,随着重复试验,我们增加了发现显著效应的概率,这就是多重比较问题。通常,解决该问题的方法是,在比较多个样本时要求更显著的效应。然而,这个问题并没有简单的解决方案;即使设置α为 0.01,我们仍然会在*均 1%的时间内犯第一类错误。
为了帮助我们更直观地理解多重比较和统计显著性之间的关系,接下来我们将构建一个互动网页,来模拟进行多次采样的效果。使用像 Clojure 这样强大且通用的编程语言进行数据分析的优势之一就是,我们可以在多种环境中运行我们的数据处理代码。
我们目前为本章编写并运行的代码是为 Java 虚拟机编译的。但自 2013 年以来,我们的编译代码有了一个替代目标环境:网页浏览器。ClojureScript 将 Clojure 的应用范围进一步扩展到了任何具有 JavaScript 功能的网页浏览器的计算机。
引入模拟
为了帮助可视化与多重显著性检验相关的问题,我们将使用 ClojureScript 构建一个交互式模拟,寻找从两个指数分布中随机抽样的样本之间的统计显著差异。为了观察其他因素如何与我们的假设检验相关联,我们的模拟将允许我们改变两个分布的基础总体均值,以及设置样本大小和所需的置信水*。
如果你已下载本章的示例代码,你将在资源目录中看到一个 index.html
文件。如果你在浏览器中打开这个文件,你应该看到一个提示信息,提示你编译 JavaScript。我们可以使用名为 cljsbuild
的 Leiningen 插件来做到这一点。
编译模拟
cljsbuild
是一个将 ClojureScript 编译为 JavaScript 的 Leiningen 插件。要使用它,我们只需让编译器知道我们希望将 JavaScript 文件输出到哪里。Clojure 代码输出为 .jar
文件(即 Java 存档),而 ClojureScript 输出为单个 .js
文件。我们通过 project.clj
文件中的 :cljsbuilds
部分指定输出文件的名称和编译器设置。
该插件可以通过命令行访问,命令为 lein cljsbuild
。在项目根目录中运行以下命令:
lein cljsbuild once
此命令将为我们编译一个 JavaScript 文件。另一种替代命令如下:
lein cljsbuild auto
上述内容将编译代码,但会保持活跃,监控源文件的更改。如果这些文件中的任何一个被更新,输出将会重新编译。
现在打开 resources/index.html
文件在浏览器中查看 JavaScript 的效果。
浏览器模拟
一个 HTML 页面已被提供在示例项目的资源目录中。在任何现代浏览器中打开该页面,你应该能看到类似以下的图像:
页面左侧显示了两个样本的双重直方图,这些样本都来自指数分布。样本生成的总体均值由网页右上角标记为 参数 的框中的滑块控制。在直方图下方是一个图表,显示基于样本的总体均值的两个概率密度。这些值是通过 t 分布计算的,参数是样本的自由度。在这些滑块下方,在标记为 设置 的框中,有另一对滑块用于设置样本大小和置信区间。调整置信区间会裁剪 t 分布的尾部;在 95% 置信区间下,只有概率分布的中央 95% 会被显示。最后,在标记为 统计数据 的框中,显示了两个样本的均值的滑块。这些值不能更改;它们是从样本中测量得出的。一个标记为 新样本 的按钮可以用来生成两个新的随机样本。观察每次生成新样本对样本均值的波动。不断生成样本,你偶尔会看到样本均值之间有显著差异,即使底层总体均值相同。
在我们探索不同样本大小和置信度对不同总体均值的影响时,让我们看看如何使用 jStat
、Reagent
和 B1
库构建这个模拟。
jStat
由于 ClojureScript 编译成 JavaScript,我们不能使用有 Java 依赖的库。Incanter 强烈依赖几个底层 Java 库,因此我们必须找到一个替代 Incanter 的浏览器端统计分析工具。
注意
在构建 ClojureScript 应用程序时,我们不能使用依赖于 Java 库的库,因为它们在执行我们代码的 JavaScript 引擎中不可用。
jStat
(github.com/jstat/jstat
) 是一个 JavaScript 统计库。它提供了根据特定分布生成序列的函数,包括指数分布和 t 分布。
要使用它,我们必须确保它在我们的网页上可用。我们可以通过链接到远程内容分发网络 (CDN) 或者自己托管文件来实现这一点。链接到 CDN 的好处是,曾经为另一个网站下载过 jStat
的访客可以使用他们的缓存版本。然而,由于我们的模拟是为本地使用而设计的,我们已经将文件包含在内,以确保即使浏览器离线,页面也能正常工作。
jstat.min.js
文件已下载到 resources/js/vendor
目录中。该文件通过标准 HTML 标签加载到 index.html
的主体部分。
为了利用 jStat 的分布生成函数,我们必须从 ClojureScript 与 JavaScript 库进行交互。与 Java 互操作一样,Clojure 提供了务实的语法来与用主机语言编写的库进行交互。
jStat
提供了各种分布,可以在jstat.github.io/distributions.html
找到相关文档。要从指数分布生成样本,我们可以调用jStat.exponential.sample(lambda)
函数。与 JavaScript 的互操作非常简单;我们只需在表达式前加上js/
,以确保访问 JavaScript 的命名空间,并调整括号的位置:
(defn randexp [lambda]
(js/jStat.exponential.sample lambda))
一旦我们能够从指数分布中生成样本,创建一个懒加载样本序列将变得简单,只需要重复调用该函数:
(defn exponential-distribution [lambda]
(repeatedly #(randexp lambda)))
ClojureScript 几乎暴露了 Clojure 的所有功能,包括懒加载序列。请参考本书的 wiki wiki.clojuredatascience.com
,获取关于 JavaScript 互操作的资源链接。
B1
现在我们可以在 ClojureScript 中生成数据样本,我们希望能够将它们绘制在直方图上。我们需要一个纯 Clojure 的替代方案,用于绘制可以在网页上访问的直方图;B1 库(github.com/henrygarner/b1
)正提供了这样的功能。这个名字源于它是从 ClojureScript 库C2
改编和简化而来的,而C2
又是流行的 JavaScript 数据可视化框架D3
的简化版。
我们将使用 B1 在b1.charts
中的简单工具函数,将数据构建为 ClojureScript 中的直方图。B1 并不强制要求特定的显示格式;我们可以使用它在 canvas 元素上绘制,或者甚至直接从 HTML 元素中构建图表。然而,B1 确实包含将图表转换为 SVG 的函数,这些图表可以在所有现代网页浏览器中显示。
可伸缩矢量图形(Scalable Vector Graphics)
SVG 代表可伸缩矢量图形(Scalable Vector Graphics),定义了一组表示绘图指令的标签。SVG 的优势在于,结果可以在任何尺寸下渲染,而不会像按比例放大的光栅(基于像素的)图形那样模糊。另一个好处是现代浏览器知道如何渲染 SVG 绘图指令,并直接在网页中生成图像,还可以使用 CSS 对图像进行样式化和动画处理。
虽然对 SVG 和 CSS 的详细讨论超出了本书的范围,但 B1 确实提供了类似 Incanter 的语法,用于使用 SVG 构建简单的图表和图形。给定一组值,我们可以调用c/histogram
函数将其转换为数据结构的内部表示。我们可以使用c/add-histogram
函数添加额外的直方图,并调用svg/as-svg
将图表渲染为 SVG 表示形式:
(defn sample-histograms [sample-a sample-b]
(-> (c/histogram sample-a :x-axis [0 200] :bins 20)
(c/add-histogram sample-b)
(svg/as-svg :width 550 :height 400)))
与 Incanter 不同,当我们选择渲染直方图时,我们还必须指定图表的期望宽度和高度。
绘制概率密度
除了使用 jStat 从指数分布中生成样本外,我们还将使用它来计算t分布的概率密度。我们可以构建一个简单的函数来封装jStat.studentt.pdf(t, df)
函数,提供正确的t统计量和自由度来参数化分布:
(defn pdf-t [t & {:keys [df]}]
(js/jStat.studentt.pdf t df))
使用 ClojureScript 的一个优势是我们已经编写了计算样本t统计量的代码。这段代码在 Clojure 中可以正常工作,并且可以在不做任何更改的情况下编译为 ClojureScript:
(defn t-statistic [test {:keys [mean n sd]}]
(/ (- mean test)
(/ sd (Math/sqrt n))))
为了渲染概率密度,我们可以使用 B1 的c/function-area-plot
。这将根据一个由函数描述的线生成面积图。提供的函数只需要接受一个x并返回相应的y。
一个小小的复杂性是我们返回的y值对于不同的样本会有所不同。这是因为t-pdf
在样本均值处(对应于t统计量为零)最高。因此,我们需要为每个样本生成一个不同的函数来传递给function-area-plot
。这通过probability-density
函数来实现,如下所示:
(defn probability-density [sample alpha]
(let [mu (mean sample)
sd (standard-deviation sample)
n (count sample)]
(fn [x]
(let [df (dec (count sample))
t-crit (threshold-t 2 df alpha)
t-stat (t-statistic x {:mean mu
:sd sd
:n n})]
(if (< (Math/abs t-stat) t-crit)
(pdf-t t-stat :df df)
0)))))
在这里,我们定义了一个高阶函数probability-density
,它接受一个单一值sample
。我们计算一些简单的汇总统计量,然后返回一个匿名函数,该函数计算分布中给定值的概率密度。
这个匿名函数将传递给function-area-plot
。它接受一个x并计算给定样本的t统计量。返回的y值是与t统计量相关的t分布的概率:
(defn sample-means [sample-a sample-b alpha]
(-> (c/function-area-plot (probability-density sample-a alpha)
:x-axis [0 200])
(c/add-function (probability-density sample-b alpha))
(svg/as-svg :width 550 :height 250)))
与直方图一样,生成多个图表和调用add-function
一样简单,只需提供图表和我们想要添加的新函数。
状态和 Reagent
在 ClojureScript 中,状态的管理方式与 Clojure 应用程序相同——通过使用原子、引用或代理。原子提供对单一身份的非协调、同步访问,是存储应用状态的绝佳选择。使用原子确保应用始终看到数据的一致视图。
Reagent 是一个 ClojureScript 库,它提供了一种机制,用于在原子值发生变化时更新网页内容。标记和状态是绑定在一起的,因此每当应用程序状态更新时,标记将重新生成。
Reagent 还提供了语法,用于使用 Clojure 数据结构以惯用方式渲染 HTML。这意味着页面的内容和交互性可以使用一种语言来处理。
更新状态
数据保存在 Reagent atom 中,更新状态是通过调用swap!
函数实现的,该函数接受两个参数——我们希望更新的 atom 和一个函数,该函数用来转换 atom 的状态。提供的函数需要接受 atom 的当前状态并返回新的状态。感叹号表示该函数具有副作用,在这里副作用是可取的;除了更新 atom 之外,Reagent 还会确保我们的 HTML 页面中的相关部分得到更新。
指数分布有一个单一的参数——速率,表示为λ(lambda)。指数分布的速率是均值的倒数,因此我们通过计算(/ 1 mean-a)
来将其作为参数传递给指数分布函数:
(defn update-sample [{:keys [mean-a mean-b sample-size]
:as state}]
(let [sample-a (->> (float (/ 1 mean-a))
(exponential-distribution)
(take sample-size))
sample-b (->> (float (/ 1 mean-b))
(exponential-distribution)
(take sample-size))]
(-> state
(assoc :sample-a sample-a)
(assoc :sample-b sample-b)
(assoc :sample-mean-a (int (mean sample-a)))
(assoc :sample-mean-b (int (mean sample-b))))))
(defn update-sample! [state]
(swap! state update-sample))
在前面的代码中,我们定义了一个update-sample
函数,它接受一个包含:sample-size
、:mean-a
和:mean-b
的映射,并返回一个包含相关新样本和样本均值的新映射。
update-sample
函数是纯函数,意思是它没有副作用,这使得它更容易测试。update-sample!
函数通过调用swap!
来封装它。Reagent 确保任何依赖于该 atom 中值的代码在 atom 中的值发生变化时都会执行。这导致我们的界面在新样本的响应下重新渲染。
绑定界面
为了将界面绑定到状态,Reagent 定义了一个render-component
函数。这个函数将一个特定的函数(在此为我们的layout-interface
函数)与一个特定的 HTML 节点(页面上 ID 为root
的元素)连接起来:
(defn layout-interface []
(let [sample-a (get @state :sample-a)
sample-b (get @state :sample-b)
alpha (/ (get @state :alpha) 100)]
[:div
[:div.row
[:div.large-12.columns
[:h1 "Parameters & Statistics"]]]
[:div.row
[:div.large-5.large-push-7.columns
[controllers state]]
[:div.large-7.large-pull-5.columns {:role :content}
[sample-histograms sample-a sample-b]
[sample-means sample-a sample-b alpha]]]]))
(defn run []
(r/render-component
[layout-interface]
(.getElementById js/document "root")))
我们的layout-interface
函数包含了作为嵌套 Clojure 数据结构表示的 HTML 标记。在对:div
和:h1
的调用之间,有两个调用我们自己的sample-histograms
和sample-means
函数。它们将被替换为它们的返回值——直方图的 SVG 表示以及均值的概率密度。
为了简洁起见,我们省略了controllers
函数的实现,它处理滑块和新建样本按钮的渲染。请查阅示例代码中的cljds.ch2.app
命名空间,查看这是如何实现的。
模拟多个测试
每次按下新建 样本按钮时,都会生成一对来自指数分布的新样本,人口均值来自滑块。样本会被绘制在直方图上,并且在下面会绘制一个概率密度函数,显示样本的标准误差。当置信区间发生变化时,可以观察到标准误差的可接受偏差也会发生变化。
每次按下按钮时,我们可以将其视为一个显著性检验,alpha 设置为置信区间的补充值。换句话说,如果样本均值的概率分布在 95%的置信区间内重叠,我们就无法在 5%的显著性水*下拒绝零假设。
请观察,即使总体均值相同,均值偶尔也会发生较大的偏差。当样本差异超过我们的标准误差时,我们可以接受备择假设。在 95%的置信水*下,即使分布的总体均值相同,我们也会在 20 次试验中发现约一次显著结果。当这种情况发生时,我们正在犯第一类错误,即将抽样误差误认为是真正的总体差异。
尽管总体参数相同,但偶尔会观察到较大的样本差异。
邦费罗尼校正
因此,在进行多重测试时,我们需要一种替代方法,以应对通过重复试验发现显著效应的概率增加。邦费罗尼校正是一种非常简单的调整方法,确保我们不太可能犯第一类错误。它通过调整我们测试的显著性水*(alpha)来实现这一点。
该调整非常简单——邦费罗尼校正只需将我们期望的显著性水*(alpha)除以我们进行的测试数量。例如,如果我们有k个网站设计要测试,并且实验的显著性水*为0.05,则邦费罗尼校正公式为:
这是减少在多重测试中发生第一类错误概率增加的安全方法。以下示例与ex-2-22
相同,不同之处在于显著性水*的值已被除以组的数量:
(defn ex-2-23 []
(let [data (->> (load-data "multiple-sites.tsv")
(:rows)
(group-by :site)
(map-vals (partial map :dwell-time)))
alpha (/ 0.05 (count data))]
(doseq [[site-a times-a] data
[site-b times-b] data
:when (> site-a site-b)
:let [p-val (-> (s/t-test times-a :y times-b)
(:p-value))]]
(when (< p-val alpha)
(println site-b "and" site-a
"are significantly different:"
(format "%.3f" p-val))))))
如果你运行前面的示例,你会发现使用邦费罗尼校正后,任何页面都不再被视为统计显著。
显著性检验是一项*衡工作——我们降低第一类错误的几率时,第二类错误的风险会增大。邦费罗尼校正非常保守,因此有可能由于过于谨慎,我们错过了真正的差异。
在本章的最后部分,我们将研究一种替代的显著性检验方法,这种方法在减少第一类错误和第二类错误之间取得*衡,同时允许我们同时测试所有的 20 个页面。
方差分析
方差分析,通常缩写为ANOVA,是一系列用于衡量组间差异的统计显著性的方法。它由极具天赋的统计学家罗纳德·费舍尔(Ronald Fisher)开发,他通过在生物学实验中的应用推广了显著性检验。
我们的测试,使用z-统计量和t-统计量,主要集中在样本均值上,作为区分两个样本的主要机制。在每种情况下,我们都寻找均值的差异,除以我们合理预期的差异水*,并通过标准误差进行量化。
均值并不是唯一可能表明样本之间存在差异的统计量。事实上,样本方差也可以作为统计差异的一个指标。
为了说明这一点,考虑前面的图示。左侧的三个组中的每一个都可以代表某一特定页面的停留时间样本,每个组都有自己的均值和标准差。如果将三个组的停留时间合并成一个,则方差比单独计算每组的*均方差要大。
方差分析(ANOVA)测试的统计显著性来源于两个方差的比率——即组间方差和组内方差。如果组间存在显著差异,但组内没有反映这种差异,那么这些分组有助于解释组间的一些方差。相反,如果组内的方差与组间的方差相同,那么这些组在统计学上并没有显著差异。
F-分布
F-分布由两个自由度参数化——一个是样本大小的自由度,另一个是组数的自由度。
第一个自由度是组数减一,第二个自由度是样本大小减去组数。如果k代表组数,n代表样本大小:
我们可以通过 Incanter 函数图来可视化不同的F分布:
前面的图示显示了不同的F分布,这些分布是基于将 100 个数据点拆分成 5 组、10 组和 50 组的结果。
F-统计量
表示组内和组间方差比率的检验统计量称为F-统计量。F-统计量越接* 1,表示两者的方差越相似。F-统计量的计算方法非常简单,如下所示:
这里,是组间的方差,而
是组内的方差。
当F比率增大时,组间方差与组内方差的比率也会增大。这意味着分组在解释整个样本观察到的方差方面表现得很好。当这个比率超过一个临界值时,我们可以说差异在统计学上是显著的。
注意
F检验始终是单尾检验,因为组间的任何方差都会使F值变大。F不可能小于零。
F检验中的组内方差是通过均值的*方偏差的*均值计算的。我们将其计算为从均值的*方偏差的和除以第一个自由度。例如,如果有k个组,每个组的均值为 ,我们可以这样计算组内方差:
这里,SSW表示组内*方和,x[jk]表示组k中j^(th)元素的值。
前面的计算SSW的公式看起来很复杂。但实际上,Incanter 定义了一个有用的 s/sum-of-square-devs-from-mean
函数,这使得计算组内*方和变得像这样简单:
(defn ssw [groups]
(->> (map s/sum-of-square-devs-from-mean groups)
(reduce +)))
F检验中的组间方差有类似的公式:
这里,SST是总*方和,SSW是我们刚刚计算的值。总*方和是从“总体”均值的*方差的总和,可以这样计算:
因此,SST只是没有任何分组的整体*方和。我们可以在 Clojure 中像这样计算SST和SSW:
(defn sst [groups]
(->> (apply concat groups)
(s/sum-of-square-devs-from-mean)))
(defn ssb [groups]
(- (sst groups)
(ssw groups)))
F统计量是通过组间方差与组内方差的比率来计算的。结合之前定义的ssb
和ssw
函数以及两个自由度,我们可以在 Clojure 中按如下方式计算F统计量。
因此,我们可以通过以下方式从我们的各组和两个自由度计算F统计量:
(defn f-stat [groups df1 df2]
(let [msb (/ (ssb groups) df1)
msw (/ (ssw groups) df2)]
(/ msbmsw)))
现在,我们可以从各组计算出F统计量,准备在F检验中使用它。
F检验
和我们在本章中查看的所有假设检验一样,一旦我们有了统计量和分布,我们只需要选择一个α值,并查看我们的数据是否超出了该检验的临界值。
Incanter 提供了一个 s/f-test
函数,但它仅衡量两组间和组内的方差。为了对我们 20 个不同的组进行F检验,我们需要实现自己的F检验函数。幸运的是,我们已经通过计算合适的F统计量,在前面的部分做了很多繁重的工作。我们可以通过查找F统计量并使用带有正确自由度的F分布来执行F检验。在下面的代码中,我们将编写一个f-test
函数,利用它对任意数量的组执行检验:
(defn f-test [groups]
(let [n (count (apply concat groups))
m (count groups)
df1 (- m 1)
df2 (- n m)
f-stat (f-stat groups df1 df2)]
(s/cdf-f f-stat :df1 df1 :df2 df2 :lower-tail? false)))
在前述函数的最后一行,我们使用 Incanter 的s/cdf-f
函数,并根据正确的自由度将F统计量转换为p值。这个p值是对整个模型的度量,表明不同页面如何解释总体停留时间的方差。我们需要做的就是选择一个显著性水*并运行测试。我们暂时选择 5%的显著性水*:
(defn ex-2-24 []
(let [grouped (->> (load-data "multiple-sites.tsv")
(:rows)
(group-by :site)
(vals)
(map (partial map :dwell-time)))]
(f-test grouped)))
;; 0.014
该测试返回了一个p值为 0.014,这是一个显著的结果。不同的页面确实有不同的方差,不能仅仅通过随机抽样误差来解释。
我们可以使用箱线图将每个网站的分布一起可视化,并将它们并排进行比较:
(defn ex-2-25 []
(let [grouped (->> (load-data "multiple-sites.tsv")
(:rows)
(group-by :site)
(sort-by first)
(map second)
(map (partial map :dwell-time)))
box-plot (c/box-plot (first grouped)
:x-label "Site number"
:y-label "Dwell time (s)")
add-box (fn [chart dwell-times]
(c/add-box-plot chart dwell-times))]
(-> (reduce add-box box-plot (rest grouped))
(i/view))))
在前面的代码中,我们对各组进行遍历,为每个组调用c/add-box-plot
。在绘制前,组按其网站 ID 进行排序,因此我们的原始页面 0 位于图表的最左侧。
看起来网站 ID 10
的停留时间最长,因为其四分位距在图表上延伸得最远。然而,如果仔细观察,你会发现它的均值低于网站 6,停留时间的*均值超过 144 秒:
(defn ex-2-26 []
(let [data (load-data "multiple-sites.tsv")
site-0 (->> (i/$where {:site {:$eq 0}} data)
(i/$ :dwell-time))
site-10 (->> (i/$where {:site {:$eq 10}} data)
(i/$ :dwell-time))]
(s/t-test site-10 :y site-0)))
;; 0.0069
现在我们已经通过F检验确认了统计显著效应,我们有理由宣称网站 ID 6
在统计上与基准存在差异:
(defn ex-2-27 []
(let [data (load-data "multiple-sites.tsv")
site-0 (->> (i/$where {:site {:$eq 0}} data)
(i/$ :dwell-time))
site-6 (->> (i/$where {:site {:$eq 6}} data)
(i/$ :dwell-time))]
(s/t-test site-6 :y site-0)))
;; 0.007
最终,我们有证据表明,页面 ID 6 相较于当前网站确实有所改进。根据我们的分析,AcmeContent 的 CEO 授权启动新版本的网站。网络团队感到非常高兴!
效应大小
在本章中,我们集中讨论了统计显著性——统计学家用来确保发现的差异不能简单地归因于随机变异的方法。我们必须始终记住,发现显著效应并不等同于发现大效应。在非常大的样本中,即使样本均值之间的差异很小,也会被视为显著。为了更好地了解我们的发现是否既显著又重要,我们还应当陈述效应大小。
Cohen's d
Cohen's d 是一个调整指标,可以帮助我们判断我们观察到的差异不仅是统计显著的,而且实际上很大。类似于 Bonferroni 校正,这个调整非常简单:
在这里,S[ab]是样本的合并标准差(而非合并标准误)。它的计算方式类似于合并标准误:
(defn pooled-standard-deviation [a b]
(i/sqrt (+ (i/sq (standard-deviation a))
(i/sq (standard-deviation b)))))
因此,我们可以计算页面 6 的 Cohen's d,如下所示:
(defn ex-2-28 []
(let [data (load-data "multiple-sites.tsv")
a (->> (i/$where {:site {:$eq 0}} data)
(i/$ :dwell-time))
b (->> (i/$where {:site {:$eq 6}} data)
(i/$ :dwell-time))]
(/ (- (s/mean b)
(s/mean a))
(pooled-standard-deviation a b))))
;; 0.389
与p值相比,Cohen's d 没有绝对的阈值。一个效应是否可以被认为是大的,部分取决于具体背景,但它确实提供了一个有用的、标准化的效应大小度量。大于 0.5 的值通常被认为是大的,因此 0.38 是一个适度的效应。它无疑代表了我们网站停留时间的显著增加,值得为网站升级付出努力。
总结
在本章中,我们学习了描述性统计和推断性统计的区别。我们再次看到了正态分布和中心极限定理的重要性,并了解了如何通过z检验、t检验和F检验量化总体差异。
我们了解了推断统计学的技巧如何通过分析样本本身来对所抽样的总体做出推断。我们见识了各种技术——置信区间、重抽样法和显著性检验——这些都能提供有关潜在总体参数的见解。通过使用 ClojureScript 模拟重复的测试,我们还洞察了多重比较中的显著性检验困难,并看到F检验如何试图解决这一问题,在第一类和第二类错误之间找到*衡。
在下一章,我们将把在方差和F检验中学到的知识应用到单个样本中。我们将介绍回归分析技术,并使用它来寻找奥林匹克运动员样本中变量之间的相关性。
第三章. 相关性
“我越了解人类,就越喜欢我的狗。” | ||
---|---|---|
--马克·吐温 |
在前面的章节中,我们已经讨论了如何通过总结统计量来描述样本,以及如何从这些统计量推断出总体参数。这种分析能告诉我们一些关于总体和样本的情况,但它并不能让我们对个别元素做出非常精确的描述。这是因为将数据简化为仅有的两个统计量:均值和标准差,导致了大量信息的丢失。
我们经常希望进一步分析,建立两个或更多变量之间的关系,或者在给定一个变量的情况下预测另一个变量。这就引出了相关性和回归的研究。相关性关注两个或更多变量之间关系的强度和方向。回归则确定这种关系的性质,并使我们能够根据它做出预测。
线性回归是我们的第一个机器学习算法。给定一组数据样本,我们的模型将学习一个线性方程,使其能够对新的、未见过的数据进行预测。为了实现这一点,我们将回到 Incanter,研究奥运运动员的身高与体重之间的关系。我们将介绍矩阵的概念,并展示如何使用 Incanter 对其进行操作。
关于数据
本章将使用伦敦 2012 年奥运会运动员的数据,感谢《卫报新闻与传媒有限公司》的支持。数据最初来源于《卫报》的优秀数据博客,网址是 www.theguardian.com/data
。
注意
从出版商网站或 github.com/clojuredatascience/ch3-correlation
下载本章的示例代码。
请参考本章示例代码中的Readme
文件,或访问本书的维基 wiki.clojuredatascience.com
了解有关数据的更多信息。
检查数据
面对一个新的数据集时,首要任务是研究它,确保我们理解它所包含的内容。
all-london-2012-athletes.xlsx
文件足够小,因此它与本章的示例代码一起提供。我们可以像在第一章中那样使用 Incanter 来检查数据,使用incanter.excel/read-xls
和incanter.core/view
函数:
(ns cljds.ch3.examples
(:require [incanter.charts :as c]
[incanter.core :as i]
[incanter.excel :as xls]
[incanter.stats :as s]))
(defn athlete-data []
(-> (io/resource "all-london-2012-athletes.xlsx")
(str)
(xls/read-xls)))
(defn ex-3-1 []
(i/view (athlete-data)))
如果你运行这段代码(无论是在 REPL 中,还是通过命令行使用lein run –e 3.1
),你应该会看到以下输出:
我们很幸运,数据在列中有明确的标签,包含以下信息:
-
运动员姓名
-
他们参赛的国家
-
年龄(年)
-
身高(厘米)
-
体重(千克)
-
性别(字符串“M”或“F”)
-
出生日期(字符串)
-
出生地(字符串,包含国家)
-
获得的金牌数量
-
获得的银牌数量
-
获得的铜牌数量
-
总共获得的金、银、铜奖牌数
-
他们参加的运动
-
事件作为逗号分隔的列表
即使数据已经清晰标注,身高、体重和出生地数据中仍然存在明显的空缺。我们必须小心,确保这些不会影响我们的分析。
数据可视化
首先,我们将考虑伦敦 2012 年奥运会运动员身高的分布。让我们将身高值绘制为直方图,看看数据是如何分布的,记得先过滤掉空值:
(defn ex-3-2 []
(-> (remove nil? (i/$ "Height, cm" (athlete-data)))
(c/histogram :nbins 20
:x-label "Height, cm"
:y-label "Frequency")
(i/view)))
这段代码生成了以下的直方图:
数据大致符合正态分布,正如我们预期的那样。我们的运动员的*均身高大约是 177 厘米。让我们看看 2012 年奥运会游泳运动员的体重分布:
(defn ex-3-3 []
(-> (remove nil? (i/$ "Weight" (athlete-data)))
(c/histogram :nbins 20
:x-label "Weight"
:y-label "Frequency")
(i/view)))
这段代码生成了以下的直方图:
这些数据呈现出明显的偏斜。尾部远长于峰值右侧,而左侧较短,因此我们说偏斜是正向的。我们可以使用 Incanter 的incanter.stats/skewness
函数来量化数据的偏斜程度:
(defn ex-3-4 []
(->> (swimmer-data)
(i/$ "Weight")
(remove nil?)
(s/skewness)))
;; 0.238
幸运的是,可以通过使用 Incanter 的incanter.core/log
函数对体重取对数,来有效减轻这种偏斜:
(defn ex-3-5 []
(-> (remove nil? (i/$ "Weight" (athlete-data)))
(i/log)
(c/histogram :nbins 20
:x-label "log(Weight)"
:y-label "Frequency")
(i/view)))
这段代码会生成以下的直方图:
这与正态分布非常接*。这表明体重是按照对数正态分布分布的。
对数正态分布
对数正态分布就是一个值集的分布,这些值的对数是正态分布的。对数的底数可以是任何大于零的数,除了 1。与正态分布一样,对数正态分布在描述许多自然现象时非常重要。
对数表示的是一个固定数(底数)必须提高到什么幂才能得到一个给定的数。通过将对数值绘制为直方图,我们展示了这些幂值大致符合正态分布。对数通常以底数 10 或底数e(一个大约等于 2.718 的超越数)来计算。Incanter 的log
函数及其逆函数exp
都使用底数e。log[e]也叫做自然对数或ln,因为它在微积分中的特殊性质使其特别适用。
对数正态分布通常出现在增长过程当中,其中增长速率与大小无关。这被称为吉布拉特法则,由罗伯特·吉布拉特于 1931 年正式定义,他注意到这一定律适用于企业的增长。由于增长速率是规模的一个比例,较大的企业往往增长得比较小的企业更快。
注意
正态分布出现在许多小变化具有加法效应的情况,而对数正态分布出现在许多小变化具有乘法效应的情况。
吉布拉特法则(Gibrat's law)已被发现适用于许多情况,包括城市的规模,以及根据 Wolfram MathWorld,乔治·伯纳德·肖(George Bernard Shaw)句子中单词的数量。
在本章的其余部分,我们将使用体重数据的自然对数,以便使我们的数据*似正态分布。我们将选择一群大致相似体型的运动员,比如奥运游泳运动员。
可视化相关性
确定两个变量是否相关的最快和最简单的方法之一是将它们绘制在散点图上。我们将筛选数据,仅选择游泳运动员,然后将身高与体重进行绘制:
(defn swimmer-data []
(->> (athlete-data)
(i/$where {"Height, cm" {:$ne nil} "Weight" {:$ne nil}
"Sport" {:$eq "Swimming"}})))
(defn ex-3-6 []
(let [data (swimmer-data)
heights (i/$ "Height, cm" data)
weights (i/log (i/$ "Weight" data))]
(-> (c/scatter-plot heights weights
:x-label "Height, cm"
:y-label "Weight")
(i/view))))
这段代码生成了以下图表:
输出清楚地显示了两个变量之间的关系。该图表具有两个相关的、正态分布的变量围绕均值居中的典型偏斜椭圆形状。下图将散点图与身高和对数体重的概率分布进行比较:
靠*一个分布尾部的点通常也会靠*另一个分布的相同尾部,反之亦然。因此,两个分布之间存在一种关系,我们将在接下来的几节中展示如何量化这种关系。不过,如果仔细观察之前的散点图,你会发现这些点因测量值四舍五入(身高和体重分别以厘米和千克为单位)而集中排列成列和行。在这种情况下,有时最好对数据进行抖动,以使关系的强度更加明显。如果不进行抖动,可能看似是一个点的地方实际上是许多个共享相同数值对的点。引入一些随机噪声可以减少这种可能性。
抖动
由于每个值都四舍五入到最接*的厘米,捕获的值为 180 时,实际上可能在 179.5 厘米到 180.5 厘米之间。为了消除这个效应,我们可以在-0.5 到 0.5 的范围内为每个身高数据点添加随机噪声。
体重数据点是按最接*的千克捕获的,因此 80 这个值实际上可能在 79.5 千克到 80.5 千克之间。我们可以在相同范围内添加随机噪声来消除这个效应(尽管显然,这必须在我们取对数之前完成):
(defn jitter [limit]
(fn [x]
(let [amount (- (rand (* 2 limit)) limit)]
(+ x amount))))
(defn ex-3-7 []
(let [data (swimmer-data)
heights (->> (i/$ "Height, cm" data)
(map (jitter 0.5)))
weights (->> (i/$ "Weight" data)
(map (jitter 0.5))
(i/log))]
(-> (c/scatter-plot heights weights
:x-label "Height, cm"
:y-label "Weight")
(i/view))))
抖动后的图表如下所示:
就像在第一章 统计学 中对散点图引入透明度一样,抖动也是一种机制,确保我们不让偶然因素——例如数据量或四舍五入的伪影——掩盖我们发现数据模式的能力。
协方差
量化两个变量之间关系强度的一种方法是它们的协方差。协方差衡量的是两个变量一起变化的趋势。
如果我们有两个序列,X和Y,它们的偏差是:
其中x[i]是索引i处X的值,y[i]是索引i处Y的值,是X的均值,
是Y的均值。如果X和Y倾向于一起变化,它们的偏差通常会有相同的符号:当它们低于均值时为负,超过均值时为正。如果我们将它们相乘,当它们具有相同符号时,积为正,当它们具有不同符号时,积为负。将这些积加起来,就得到了一个衡量两个变量在每个给定样本中倾向于朝相同方向偏离均值的程度的指标。
协方差被定义为这些积的均值:
协方差可以使用以下代码在 Clojure 中计算:
(defn covariance [xs ys]
(let [x-bar (s/mean xs)
y-bar (s/mean xs)
dx (map (fn [x] (- x x-bar)) xs)
dy (map (fn [y] (- y y-bar)) ys)]
(s/mean (map * dx dy))))
或者,我们可以使用incanter.stats/covariance
函数。我们的奥运游泳运动员的身高和对数体重的协方差为1.354
,但这是一个难以解释的数字。单位是输入单位的乘积。
因此,协方差通常不会单独作为总结性统计量报告。为了使这个数字更易于理解,可以将偏差除以标准差的乘积。这样可以将单位转换为标准分数,并将输出限制在-1
和+1
之间。结果被称为皮尔逊相关性。
皮尔逊相关性
皮尔逊相关性通常用变量名r表示,并通过以下方式计算,其中dx[i]和dy[i]的计算方法与之前相同:
由于标准差是变量X和Y的常数值,方程可以简化为以下形式,其中σ[x]和σ[y]分别是X和Y的标准差:
这有时被称为皮尔逊的乘积矩相关系数,或简称为相关系数,通常用字母r表示。
我们之前已经写过计算标准差的函数。结合我们用来计算协方差的函数,得出了以下皮尔逊相关性的实现:
(defn correlation [x y]
(/ (covariance x y)
(* (standard-deviation x)
(standard-deviation y))))
另外,我们可以使用incanter.stats/correlation
函数。
由于标准分数是无单位的,因此r也是无单位的。如果r为-1.0 或 1.0,则表示变量之间完全负相关或完全正相关。
然而,如果r为零,并不意味着变量之间没有相关性。皮尔逊相关性只衡量线性关系。变量之间可能仍然存在一些非线性关系,而这些关系并未被r捕捉到,正如以下图示所示:
请注意,由于y的标准差为零,中心示例的相关性是未定义的。由于我们的r方程会涉及将协方差除以零,因此结果是没有意义的。在这种情况下,变量之间不能存在任何相关性;y的值始终是均值。通过简单检查标准差可以确认这一点。
可以为我们游泳选手的身高和对数体重数据计算相关系数:
(defn ex-3-8 []
(let [data (swimmer-data)
heights (i/$ "Height, cm" data)
weights (i/log (i/$ "Weight" data))]
(correlation heights weights)))
这得出了答案0.867
,它量化了我们在散点图中已经观察到的强正相关。
样本 r 与总体 rho
就像均值或标准差一样,相关系数是一种统计量。它描述了一个样本;在这种情况下,是一组配对值:身高和体重。虽然我们已知的样本相关系数用字母r表示,但未知的总体相关系数用希腊字母 rho 表示:。
正如我们在上一章中发现的,我们不应假设在样本中测得的内容适用于整个总体。在这种情况下,我们的总体可能是所有最*奥运会的游泳选手。例如,不应将结论推广到其他奥林匹克项目,如举重,或者非竞技游泳选手。
即使在一个适当的群体中——例如最*奥运会的游泳选手——我们的样本只是众多潜在样本中的一个,具有不同的相关系数。我们能多大程度上信任我们的r作为的估计,将取决于两个因素:
-
样本的大小
-
r的大小
显然,对于一个公*的样本,它越大,我们就越能信任它代表整个总体。也许你不会直观地意识到r的大小也会影响我们有多大信心它代表。原因是,大的相关系数较不可能是偶然或随机抽样误差造成的。
假设检验
在上一章中,我们介绍了假设检验作为量化给定假设(例如两个样本来自同一人群)为真的概率的方法。我们将使用相同的过程来量化基于我们样本的相关性在更广泛人群中存在的概率。
首先,我们必须提出两个假设:一个零假设和一个备择假设:
H[0]是假设人群相关性为零。换句话说,我们的保守观点是测得的相关性纯粹是由于随机抽样误差。
H[1]是备择假设,即总体相关性不为零。请注意,我们并没有指定相关性的方向,只是说明存在相关性。这意味着我们正在进行双尾检验。
样本r的标准误差为:
这个公式只有在接*零时才准确(记住r的大小会影响我们的置信度),但幸运的是,这正是我们在原假设下假设的情况。
我们再次可以利用t-分布并计算我们的t-统计量:
df是我们数据的自由度。对于相关性检验,自由度是n - 2,其中n是样本的大小。将该值代入公式,我们得到:
这给了我们一个t-值为102.21
。要将其转换为p值,我们需要参考t-分布。Incanter 提供了t-分布的累积分布函数(CDF),可以通过incanter.stats/cdf-t
函数获得。CDF 的值对应于单尾检验的p-值。由于我们进行的是双尾检验,因此我们将值乘以二:
(defn t-statistic [x y]
(let [r (correlation x y)
r-square (* r r)
df (- (count x) 2)]
(/ (* r df)
(i/sqrt (- 1 r-square)))))
(defn ex-3-9 []
(let [data (swimmer-data)
heights (i/$ "Height, cm" data)
weights (i/log (i/$ "Weight" data))
t-value (t-statistic heights weights)
df (- (count heights) 2)
p (* 2 (s/cdf-t t-value :df df :lower-tail? false))]
(println "t-value" t-value)
(println "p value " p)))
p-值非常小,几乎为零,意味着原假设为真的可能性几乎不存在。我们必须接受备择假设。
置信区间
确定了在更广泛的人群中确实存在相关性后,我们可能希望通过计算置信区间来量化我们期望落入的值范围。就像前一章中均值的情况一样,r的置信区间表达了r在两特定值之间落入的概率(以百分比表示),即该总体参数
落入这两个值之间的概率。
然而,当试图计算相关系数的标准误差时,问题出现了,这个标准误差在均值的情况下并不存在。因为r的绝对值不能超过1,所以当r接*其范围的极限时,r的可能样本分布会发生偏斜。
之前的图表展示了对于为 0.6 的r样本的负偏态分布。
幸运的是,一种名为费舍尔 z 变换的转换方法可以稳定r在其范围内的方差。这类似于我们在取对数后,体重数据变得呈正态分布的情况。
z-变换的公式为:
z的标准误差为:
因此,计算置信区间的过程是将r通过z-变换转换为z,计算以SE[z]为单位的置信区间,然后再将置信区间转换回r。
为了计算以SE[z]为单位的置信区间,我们可以计算从均值开始,给出所需置信度的标准差数。1.96 是常用的数值,因为它是离均值 1.96 个标准差的距离,包含了 95%的区域。换句话说,离样本r均值 1.96 个标准误差的范围包含了真实的总体相关性ρ,其置信度为 95%。
我们可以使用 Incanter 的incanter.stats/quantile-normal
函数来验证这一点。该函数将返回与给定累积概率相关的标准分数,假设是单尾检验。
然而,正如前面的图示所示,我们希望从每一侧减去相同的值——2.5%——这样 95%的置信区间就会以零为中心。一个简单的变换是,在执行双尾检验时,将差值的一半*移到 100%的范围内。因此,95%的置信度意味着我们查找 97.5%临界值:
(defn critical-value [confidence ntails]
(let [lookup (- 1 (/ (- 1 confidence) ntails))]
(s/quantile-normal lookup)))
(critical-value 0.95 2)
=> 1.96
因此,我们在z-空间中,95%置信区间对于为:
将我们的z[r]和SE[z]公式代入后得到:
对于r = 0.867
和n = 859
,这给出了下限和上限分别为1.137
和1.722
。要将这些从z-分数转换回r-值,我们使用以下公式,它是z-变换的逆运算:
可以使用以下代码计算变换和置信区间:
(defn z->r [z]
(/ (- (i/exp (* 2 z)) 1)
(+ (i/exp (* 2 z)) 1)))
(defn r-confidence-interval [crit x y]
(let [r (correlation x y)
n (count x)
zr (* 0.5 (i/log (/ (+ 1 r)
(- 1 r))))
sez (/ 1 (i/sqrt (- n 3)))]
[(z->r (- zr (* crit sez)))
(z->r (+ zr (* crit sez)))]))
(defn ex-3-10 []
(let [data (swimmer-data)
heights (i/$ "Height, cm" data)
weights (i/log (i/$ "Weight" data))
interval (r-confidence-interval 1.96 heights weights)]
(println "Confidence Interval (95%): " interval)))
这给出了 95%置信度区间,对于,区间为
0.850
到0.883
。我们可以非常有信心地认为,奥林匹克级游泳运动员身高与体重之间存在较强的正相关关系。
回归
尽管知道两个变量之间存在相关性可能是有用的,但仅凭此信息我们无法预测奥林匹克游泳选手的体重是否由身高决定,反之亦然。在建立相关性时,我们测量的是关系的强度和符号,而不是斜率。要进行预测,必须知道给定单位变化下,一个变量的预期变化率。
我们希望确定一个方程,关联一个变量的具体值,称为自变量,与另一个变量的期望值,称为因变量。例如,如果我们的线性方程根据身高预测体重,那么身高就是自变量,体重则是因变量。
注意
这些方程式描述的直线被称为回归线。这一术语由 19 世纪英国博学家弗朗西斯·高尔顿爵士提出。他和他的学生卡尔·皮尔逊(定义了相关系数)在 19 世纪发展了多种方法来研究线性关系,这些方法统称为回归技术。
请记住,相关性并不意味着因果关系,且依赖与独立这两个术语并不暗示因果关系——它们只是数学输入和输出的名称。一个经典的例子是消防车派遣数量与火灾造成损失之间的高度正相关。显然,派遣消防车本身并不会造成损失。没有人会建议减少派遣到火灾现场的消防车数量来减少损失。在这种情况下,我们应寻找一个与其他变量因果相关的额外变量,来解释它们之间的相关性。在前一个例子中,这个变量可能是火灾的规模。这种隐藏的原因被称为混杂 变量,因为它们混淆了我们确定依赖变量之间关系的能力。
线性方程式
两个变量,记作 x 和 y,可能是精确或不精确地相互关联的。最简单的关系是一个独立变量 x 和一个依赖变量 y 之间的直线关系,公式如下:
在这里,参数 a 和 b 的值分别决定了直线的精确高度和陡峭度。参数 a 被称为截距或常数,b 被称为梯度或斜率。例如,在摄氏度与华氏度之间的映射中,a = 32 且 b = 1.8。将这些 a 和 b 的值代入我们的方程式中,得到:
要计算 10 摄氏度对应的华氏度,我们将 10 代入 x:
因此,我们的方程式告诉我们,10 摄氏度等于 50 华氏度,这确实是正确的。使用 Incanter,我们可以轻松编写一个将摄氏度转换为华氏度的函数,并使用 incanter.charts/function-plot
绘制其图形:
(defn celsius->fahrenheit [x]
(+ 32 (* 1.8 x)))
(defn ex-3-11 []
(-> (c/function-plot celsius->fahrenheit -10 40
:x-label "Celsius"
:y-label "Fahrenheit")
(i/view)))
这段代码生成了以下线性图:
请注意,红色线在摄氏度刻度上的零点与华氏度刻度上的 32 度交叉。截距 a 是 x 为零时 y 的值。
直线的斜率由 b 决定;在这个方程式中,斜率接* 2。可以看到,华氏度的范围几乎是摄氏度范围的两倍。换句话说,这条直线在竖直方向的变化几乎是水*方向变化的两倍。
残差
不幸的是,我们研究的大多数关系并不像摄氏温度与华氏温度之间的映射那样简洁。直线方程很少允许我们精确地用 x 来表示 y。通常会存在误差,因此:
这里,ε 是误差项,表示给定 x 值时,由参数 a 和 b 计算的值与实际 y 值之间的差异。如果我们预测的 y 值是 (读作“y-hat”),那么误差就是两者之间的差异:
这个误差被称为残差。残差可能是由于随机因素(如测量误差)或非随机因素(如未知的因素)造成的。例如,如果我们试图将体重作为身高的函数进行预测,未知的因素可能包括饮食、健康水*和体型(或简单地四舍五入到最*的千克)。
如果我们选择了不理想的 a 和 b 参数,那么每个 x 的残差将比必要的要大。因此,可以得出结论,我们希望找到的参数是那些在所有 x 和 y 值中最小化残差的参数。
普通最小二乘法
为了优化我们线性模型的参数,我们希望设计一个成本函数,也称为损失函数,来量化我们的预测与数据的契合程度。我们不能简单地将正负残差相加,因为即使是大的残差,如果它们的符号方向相反,也会互相抵消。
我们可以在计算总和之前对值进行*方,以便正负残差都能计入成本。这还具有对大误差进行更大惩罚的效果,但并不会导致最大的残差总是主导。
作为一个优化问题,我们寻求识别能够最小化残差*方和的系数。这叫做普通最小二乘法(OLS),使用 OLS 计算回归线斜率的公式是:
尽管这看起来比之前的方程复杂,但它实际上只是残差*方和除以与均值的*方差之和。它与我们之前看到的方程式有许多相同的项,并且可以简化为:
截距是允许具有这种斜率的直线通过 X 和 Y 的均值的项:
这些 a 和 b 的值是我们最小二乘估计的系数。
斜率和截距
我们已经编写了计算游泳身高和体重数据的斜率和截距所需的 协方差
、方差
和 均值
函数。因此,斜率和截距的计算是简单的:
(defn slope [x y]
(/ (covariance x y)
(variance x)))
(defn intercept [x y]
(- (s/mean y)
(* (s/mean x)
(slope x y))))
(defn ex-3-12 []
(let [data (swimmer-data)
heights (i/$ "Height, cm" data)
weights (i/log (i/$ "Weight" data))
a (intercept heights weights)
b (slope heights weights)]
(println "Intercept: " a)
(println "Slope: " b)))
输出给出的斜率大约是0.0143
,截距大约是1.6910
。
解释
截距值是当自变量(height
)为零时因变量(log 体重)的值。为了知道这个值对应的公斤数,我们可以使用incanter.core/exp
函数,它是incanter.core/log
函数的逆运算。我们的模型似乎表明,零身高的奥运游泳运动员的体重最佳估计值为 5.42 公斤。这没有实际意义,因此在没有数据支持的情况下,外推训练数据之外的预测是不明智的。
斜率值显示了每单位x变化时,y的变化量。我们的模型表明,每增加一厘米的身高,奥运游泳运动员的体重大约增加 1.014 公斤。由于我们的模型基于所有奥运游泳运动员,这表示身高增加一个单位的*均效应,而没有考虑其他因素,如年龄、性别或体型。
可视化
我们可以通过incanter.charts/function-plot
和一个简单的x函数来可视化我们的线性方程输出,该函数根据系数a和b计算 。
(defn regression-line [a b]
(fn [x]
(+ a (* b x))))
(defn ex-3-13 []
(let [data (swimmer-data)
heights (->> (i/$ "Height, cm" data)
(map (jitter 0.5)))
weights (i/log (i/$ "Weight" data))
a (intercept heights weights)
b (slope heights weights)]
(-> (c/scatter-plot heights weights
:x-label "Height, cm"
:y-label "log(Weight)")
(c/add-function (regression-line a b) 150 210)
(i/view))))
regression-line
函数返回一个关于x的函数,用于计算 。
我们还可以使用regression-line
函数来计算每个残差,展示我们估算的 与每个测量的y之间的偏差。
(defn residuals [a b x y]
(let [estimate (regression-line a b)
residual (fn [x y]
(- y (estimate x)))]
(map residual x y)))
(defn ex-3-14 []
(let [data (swimmer-data)
heights (->> (i/$ "Height, cm" data)
(map (jitter 0.5)))
weights (i/log (i/$ "Weight" data))
a (intercept heights weights)
b (slope heights weights)]
(-> (c/scatter-plot heights (residuals a b heights weights)
:x-label "Height, cm"
:y-label "Residuals")
(c/add-function (constantly 0) 150 210)
(i/view))))
残差图是一个图表,展示了y轴上的残差和x轴上的自变量。如果残差图中的点在水*轴周围随机分布,则说明线性模型对数据拟合良好:
除了图表左侧的一些离群点外,残差图似乎表明线性模型对数据拟合良好。绘制残差图对于验证线性模型是否合适非常重要。线性模型对数据有某些假设,如果这些假设被违反,所构建的模型将失效。
假设
显然,线性回归的主要假设是因变量和自变量之间存在线性关系。此外,残差不能相互相关,也不能与自变量相关。换句话说,我们期望误差相对于因变量和自变量的均值为零且方差恒定。残差图可以帮助我们快速判断是否符合这一假设。
我们的残差图左侧的残差大于右侧,这对应于较矮运动员的体重方差较大。当一个变量的方差随着另一个变量的变化而变化时,这些变量被称为异方差。在回归分析中,这是一个需要关注的问题,因为它会使得假设建模误差不相关、正态分布,并且它们的方差不随建模效应变化的假设失效。
我们的残差的异方差性相对较小,应该不会对模型的质量产生太大影响。如果图表左侧的方差更为明显,则会导致最小二乘法估计的方差不准确,进而影响我们基于标准误差所做的推断。
拟合优度与 R *方
尽管我们从残差图中可以看出线性模型很好地拟合了我们的数据,但我们仍然希望量化其拟合程度。决定系数(也称为R²)的值介于零和一之间,表示线性回归模型的解释能力。它计算了因变量的变化比例,其中由自变量解释或解释的部分。
通常情况下,R² 越接* 1,回归线对数据点的拟合程度越好,Y 的变化越多地被 X 解释。R² 可以通过以下公式计算:
这里,var(ε) 是残差的方差,var(Y) 是 Y 的方差。为了理解这意味着什么,假设你正在尝试猜测某人的体重。如果你对他们没有任何其他了解,你最好的策略是猜测该人群体重的均值。这样,你的猜测与他们真实体重之间的均方误差将是 var(Y),即该人群体重的方差。
但是,如果我告诉你他们的身高,你就可以根据回归模型猜测 。在这种情况下,你的均方误差将是 var(ε),即模型残差的方差。
术语 var(ε)/ var(Y) 是带有和不带有解释变量的均方误差的比率,它表示模型未能解释的变异部分。补充的 R² 是模型解释的变异部分的比例。
注
与 r 一样,低 R² 并不意味着这两个变量不相关。它可能仅仅是因为它们之间的关系不是线性的。
R² 值描述了拟合线与数据的吻合度。最佳拟合线是使 R² 值最小化的那条线。随着系数远离最优值,R² 会始终增加。
左边的图表展示了一个始终猜测y均值的模型的方差,右边的图表显示了与模型f未能解释的残差相关的较小的方块。从几何上看,你可以看到模型已经解释了y的大部分方差。以下代码通过将残差的方差与y值的方差相除来计算R²:
(defn r-squared [a b x y]
(let [r-var (variance (residuals a b x y))
y-var (variance y)]
(- 1 (/ r-var y-var))))
(defn ex-3-15 []
(let [data (swimmer-data)
heights (i/$ "Height, cm" data)
weights (i/log (i/$ "Weight" data))
a (intercept heights weights)
b (slope heights weights)]
(r-squared a b heights weights)))
这给出了0.753
的值。换句话说,2012 年奥运会游泳选手体重的超过 75%的方差可以通过身高来解释。
在简单回归模型的情况下(只有一个自变量),决定系数R²和相关系数r之间的关系是直接的:
0.5 的相关系数可能意味着Y的变化性有一半是由X解释的,但实际上,R²将是 0.5²,或者是 0.25。
多元线性回归
到目前为止,我们在本章中已经介绍了如何使用一个自变量建立回归线。然而,通常我们希望建立一个包含多个自变量的模型。这就是多元线性回归。
每个自变量都需要它自己的系数。为了不通过字母表来表示每一个变量,我们可以指定一个新的变量β,发音为“beta”,用来表示我们所有的系数:
这个模型相当于我们的双变量线性回归模型,只要我们确保x[1]始终等于 1,就可以像和
那样。这确保了β[1]始终是一个常数因子,表示我们的截距。x[1]被称为偏置 项。
通过用 beta 来推广线性方程,容易扩展到任意数量的系数:
x[1]到x[n]的每个值对应一个可能有助于解释y值的自变量。β[1]到β[n]的每个值对应一个系数,决定了这个自变量的相对贡献。
我们的简单线性回归旨在仅通过身高来解释体重,但还有许多其他因素有助于解释一个人的体重:他们的年龄、性别、饮食和体型。我们知道奥运游泳选手的年龄,所以我们也可以建立一个包含这些附加数据的模型。
我们一直提供作为一个单一序列的自变量值,但有多个参数时,我们需要为每个x提供多个值。我们可以使用 Incanter 的i/$
函数来选择多个列并将每个x作为 Clojure 向量来处理,但有一种更好的方法:矩阵。
矩阵
矩阵是一个二维的数字网格。它的维度是通过矩阵中的行和列的数量来表示的。
例如,A 是一个有四行两列的矩阵:
在数学符号中,矩阵通常会分配给一个大写字母变量,以区分它与方程式中的其他变量。
我们可以使用 Incanter 的 incanter.core/to-matrix
函数从数据集构建一个矩阵:
(defn ex-3-16 []
(->> (swimmer-data)
(i/$ ["Height, cm" "Weight"])
(i/to-matrix)))
Incanter 还定义了 incanter.core/matrix
函数,该函数可以将一个标量值序列或一个序列的序列转换为矩阵(如果可能的话):
(defn ex-3-17 []
(->> (swimmer-data)
(i/$ "Height, cm")
(i/matrix)))
如果在 REPL 中运行此代码,输出将是矩阵内容的摘要:
A 859x1 matrix
---------------
1.66e+02
1.92e+02
1.73e+02
...
1.88e+02
1.87e+02
1.83e+02
Incanter 返回的表示形式与前面的例子完全相同,仅展示矩阵的前三行和后三行。矩阵往往非常大,Incanter 会确保不会让 REPL 被大量信息淹没。
维度
i^(th) 行 j^(th) 列的元素被称为 A[ij]。因此,在我们之前的例子中:
矩阵的一个最基本的属性是它的大小。Incanter 提供了 incanter.core/dim
、ncol
和 nrow
函数来查询矩阵的维度。
向量
向量是只有一列的特殊矩阵。向量的行数被称为它的维度:
在这里,y 是一个四维向量。i^(th) 元素被称为 y[i]。
数学文献中的向量通常是从 1 开始索引的,除非另有说明。所以,y[1] 是指第一个元素,而不是第二个。向量在方程式中通常分配给小写字母变量。Incanter 的 API 并不区分向量和单列矩阵,我们可以通过传递一个单一序列给 incanter.core/matrix
函数来创建一个向量。
构建
如我们所见,可以通过 Clojure 序列和 Incanter 数据集来构建矩阵。也可以通过更小的构建块来构建矩阵,前提是维度兼容。Incanter 提供了 incanter.core/bind-columns
和 incanter.core/bind-rows
函数,可以将矩阵堆叠在一起或并排放置。
例如,我们可以通过以下方式将一个全是 1 的列加到另一个矩阵的前面:
(defn add-bias [x]
(i/bind-columns (repeat (i/nrow x) 1) x))
实际上,我们会希望这样做来处理我们的偏置项。回忆一下,β[1] 将代表一个常数值,因此我们必须确保相应的 x[1] 也是常数。如果没有偏置项,当 x 的值为零时,y 也必须为零。
加法和标量乘法
标量是一个简单数字的名称。当我们将标量加到矩阵时,就好像我们将该数字加到矩阵的每个元素上一样,逐个元素。Incanter 提供了 incanter.core/plus
函数来将标量和矩阵相加。
矩阵加法是通过将每个对应位置的元素相加来实现的。只有维度相同的矩阵才能相加。如果矩阵的维度相同,则称它们是兼容的。
plus
函数也可以相加兼容的矩阵。minus
函数会减去标量或兼容的矩阵。将矩阵与标量相乘会导致矩阵中的每个元素都与该标量相乘。
incanter.core/mult
执行矩阵与标量的乘法,而incanter.core/div
执行逆运算。
我们也可以在兼容的矩阵上使用mult
和div
,但这种逐元素的乘法和除法并不是我们通常在谈论矩阵乘法时所指的内容。
矩阵-向量乘法
矩阵乘法的标准方法是通过incanter.core/mmult
函数来处理的,该函数应用了复杂的矩阵乘法算法。例如,将一个 3 x 2 的矩阵与一个 2 x 1 的矩阵相乘,结果是一个 3 x 1 的矩阵。乘法的左边列数必须与右边的行数匹配:
为了得到Ax,需要将A的每一行与x的对应元素逐一相乘并求和。例如,矩阵A的第一行包含元素1和3,这些元素分别与向量x中的元素1和5相乘。然后,将这些乘积相加得到16。这就是点积,也是我们通常所说的矩阵乘法。
矩阵-矩阵乘法
矩阵-矩阵乘法的过程与矩阵-向量乘法非常相似。通过逐行逐列地从矩阵A和B的对应元素中求得乘积之和。
和之前一样,只有当第一个矩阵的列数等于第二个矩阵的行数时,我们才能将矩阵相乘。如果第一个矩阵A的维度是,而第二个矩阵B的维度是
,则只有当n[a]和m[B]相等时,矩阵才能相乘。
在之前的视觉示例中:
幸运的是,我们不需要自己记住这个过程。Incanter 使用非常高效的算法为我们执行矩阵代数运算。
转置
转置一个矩阵意味着将矩阵沿从左上角到右下角的主对角线翻转。矩阵A的转置表示为A^T:
列和行已经发生了变化,因此:
因此,如果:
然后:
Incanter 提供了 incanter.core/trans
函数来转置矩阵。
单位矩阵
某些矩阵具有特殊的性质,并且在矩阵代数中经常使用。最重要的其中之一就是单位矩阵。它是一个方阵,主对角线上的元素为 1,其他位置的元素为 0:
单位矩阵是矩阵乘法的单位元。就像用数字 1 进行标量乘法一样,使用单位矩阵进行矩阵乘法没有任何影响。
Incanter 提供了 incanter.core/identity-matrix
函数来构造单位矩阵。由于单位矩阵总是方阵,因此我们只需要提供一个参数,它同时对应宽度和高度。
求逆
如果我们有一个方阵 A,则 A 的逆矩阵记作 A^(-1),它将具有以下性质,其中 I 是单位矩阵:
单位矩阵是它自身的逆矩阵。并非所有矩阵都有可逆性,不可逆矩阵也称为奇异矩阵或退化矩阵。我们可以使用 incanter.core/solve
函数来计算矩阵的逆。如果传入奇异矩阵,solve
会引发异常。
正规方程
现在我们已经掌握了矩阵和向量操作的基础,可以开始研究正规方程。这是一个利用矩阵代数来计算 OLS 线性回归模型系数的方程:
我们读作“为了找到 β,将 X 转置的逆矩阵乘以 X 转置,再乘以 y”,其中 X 是包含独立变量(包括截距项)的矩阵,y 是包含样本依赖变量的向量。结果 β 包含了计算出的系数。这个正规方程相对容易从多元回归方程中推导出来,应用矩阵乘法规则,但其数学内容超出了本书的讨论范围。
我们可以使用刚才遇到的函数,在 Incanter 中实现正规方程:
(defn normal-equation [x y]
(let [xtx (i/mmult (i/trans x) x)
xtxi (i/solve xtx)
xty (i/mmult (i/trans x) y)]
(i/mmult xtxi xty)))
这个正规方程以非常简洁的方式表达了最小二乘法线性回归的数学原理。我们可以如下使用它(记得添加偏置项):
(defn ex-3-18 []
(let [data (swimmer-data)
x (i/matrix (i/$ "Height, cm" data))
y (i/matrix (i/log (i/$ "Weight" data)))]
(normal-equation (add-bias x) y)))
这会得到以下矩阵:
A 2x1 matrix
-------------
1.69e+00
1.43e-02
这些是 β[1] 和 β[2] 的值,分别对应截距和斜率参数。幸运的是,它们与我们之前计算出的值一致。
更多功能
正规方程的一个强大之处在于,我们现在已经实现了支持多元线性回归所需的所有内容。让我们写一个函数,将感兴趣的特征转换为矩阵:
(defn feature-matrix [col-names dataset]
(-> (i/$ col-names dataset)
(i/to-matrix)))
这个函数将允许我们一步选择特定的列作为矩阵。
注意
特征是自变量的同义词,通常在机器学习中使用。其他同义词包括预测变量、回归变量、解释变量,或简称输入变量。
首先,让我们选择身高和年龄作为我们的两个特征:
(defn ex-3-19 []
(feature-matrix ["Height, cm" "Age"] (swimmer-data)))
这将返回以下两列矩阵:
A 859x2 matrix
---------------
1.66e+02 2.30e+01
1.92e+02 2.20e+01
1.73e+02 2.00e+01
...
1.88e+02 2.40e+01
1.87e+02 1.90e+01
1.83e+02 2.20e+01
我们的普通方程函数将接受这个新的矩阵,且无需进一步修改:
(defn ex-3-20 []
(let [data (swimmer-data)
x (->> data
(feature-matrix ["Height, cm" "Age"])
(add-bias))
y (->> (i/$ "Weight" data)
(i/log)
(i/matrix))]
(normal-equation x y)))
它将返回以下系数:
A 3x1 matrix
-------------
1.69e+00
1.40e-02
2.80e-03
这三个数字分别对应截距、身高的斜率和年龄的斜率。为了确定通过这些新数据我们的模型是否有显著改进,我们可以计算新模型的 R² 值,并与之前的模型进行比较。
多个 R *方
在之前计算 R² 时,我们看到了它是模型解释的方差量:
由于方差即为均方误差,我们可以将 var(ε) 和 var(y) 两项分别乘以样本大小,得到 R² 的另一种计算公式:
这只是残差*方和与均值*方差之和的比值。Incanter 包含了 incanter.core/sum-of-squares
函数,使得这一表达变得非常简单:
(defn r-squared [coefs x y]
(let [fitted (i/mmult x coefs)
residuals (i/minus y fitted)
differences (i/minus y (s/mean y))
rss (i/sum-of-squares residuals)
ess (i/sum-of-squares differences)]
(- 1 (/ rss ess))))
我们使用变量名 rss
表示残差*方和,ess
表示解释*方和。我们可以按以下方式计算新模型的矩阵 R²:
(defn ex-3-21 []
(let [data (swimmer-data)
x (->> (feature-matrix ["Height, cm" "Age"] data)
(add-bias))
y (->> (i/$ "Weight" data)
(i/log)
(i/matrix))
beta (normal-equation x y)]
(r-squared beta x y)))
这得出了值 0.757
。通过加入年龄值,我们的 R² 值略有增加。因为我们使用了多个自变量,所以 R² 现在被称为多重决定系数。
调整 R *方
随着我们向回归模型中添加更多自变量,我们可能会受到 R² 值总是增加这一现象的鼓舞。添加一个新的自变量不会使得预测因变量变得更困难——如果新变量没有解释能力,那么它的系数将会是零,R² 也将保持不变。
然而,这并不能告诉我们模型是否因为添加了新变量而得到改进。如果我们想知道新变量是否真的帮助生成了更好的拟合度,我们可以使用调整后的 R²,通常写作 ,并读作 "R-bar *方"。与 R² 不同,
只有在新自变量增加的 R² 超过由于偶然因素所期望的增幅时,它才会增加:
(defn matrix-adj-r-squared [coefs x y]
(let [r-squared (matrix-r-squared coefs x y)
n (count y)
p (count coefs)]
(- 1
(* (- 1 r-squared)
(/ (dec n)
(dec (- n p)))))))
调整后的 R² 依赖于两个附加参数,n 和 p,分别对应样本大小和模型参数的数量:
(defn ex-3-22 []
(let [data (swimmer-data)
x (->> (feature-matrix ["Height, cm" "Age"] data)
(add-bias))
y (->> (i/$ "Weight" data)
(i/log)
(i/matrix))
beta (normal-equation x y)]
(adj-r-squared beta x y)))
这个示例返回了值 0.756
。这仍然大于原始模型,因此年龄无疑具有一定的解释力。
Incanter 的线性模型
尽管实现我们自己的标准方程和 R² 为引入矩阵代数提供了宝贵的机会,但值得注意的是,Incanter 提供了 incanter.stats/linear-model
函数,涵盖了我们所讲的所有内容,甚至更多。
该函数期望以 y 和 x(可以是序列,或在多元回归的情况下是矩阵)作为输入。我们还可以传入一个可选的关键字参数 intercept
,其值为布尔型,指示是否希望 Incanter 为我们添加截距项。该函数将返回一个包含线性模型系数的映射—:coefs
和拟合数据—:fitted
,以及 :residuals
、:r-square
和 :adj-r-square
等。
它还会返回显著性测试和系数的 95% 置信区间,分别作为 :t-probs
和 :coefs-ci
键,以及 :f-prob
键,后者对应于整个回归模型的显著性测试。
模型显著性的 F 检验
linear-model
返回的 :f-prob
键是对整个模型进行的显著性测试,使用的是 F 检验。正如我们在上一章中发现的,F 检验适用于同时进行多个显著性检验。在多元线性回归的情况下,我们检验的是除截距项外,模型中的任何系数是否与零在统计上不可区分。
因此,我们的原假设和备择假设为:
这里,j 是参数向量中的某个索引,不包括截距项。我们计算的 F 统计量是已解释方差与未解释(残差)方差的比率。可以表示为 模型均方 (MSM) 与 均方误差 (MSE) 的比值:
MSM 等于 解释*方和 (ESS) 除以模型的自由度,其中模型自由度是模型中参数的数量,不包括截距项。MSE 等于 残差*方和 (RSS) 除以残差自由度,其中残差自由度是样本大小减去模型参数的数量。
一旦我们计算出 F 统计量,就可以在具有相同两个自由度的 F 分布中查找它:
(defn f-test [y x]
(let [coefs (normal-equation x y)
fitted (i/mmult x coefs)
difference (i/minus fitted (s/mean y))
residuals (i/minus y fitted)
ess (i/sum-of-squares difference)
rss (i/sum-of-squares residuals)
p (i/ncol x)
n (i/nrow y)
df1 (- p 1)
df2 (- n p)
msm (/ ess df1)
mse (/ rss df2)
f-stat (/ msm mse)]
(s/cdf-f f-stat :df1 df1 :df2 df2 :lower-tail? false)))
(defn ex-3-23 []
(let [data (swimmer-data)
x (->> (feature-matrix ["Height, cm" "Age"] data)
(add-bias))
y (->> (i/$ "Weight" data)
(i/log))
beta (:coefs (s/linear-model y x :intercept false))]
(f-test beta x y)))
测试返回结果 1.11x10e-16
。这是一个非常小的数字;因此,我们可以确信模型是显著的。
请注意,对于较小的样本数据,F 检验量化了线性模型是否合适的不确定性。例如,对于一个五个数据点的随机样本,数据有时几乎没有任何线性关系,F 检验在 50% 置信区间下甚至认为数据不显著。
类别变量和虚拟变量
此时,我们可能会尝试将"Sex"
作为回归分析中的特征,但我们会遇到一个问题。输入是以"M"
或"F"
表示的,而不是数字。这是一个分类变量的例子:一个可以取有限集无序且通常非数字的值的变量。其他分类变量的例子包括运动员参与的体育项目或他们最擅长的具体项目。
普通最小二乘法依赖于残差距离的数值来最小化。那么,游泳和田径之间的数值距离可能是多少呢?这可能意味着无法将分类变量包含在回归方程中。
注意
分类变量或名义变量与连续变量不同,因为它们不在数轴上。有时类别会用数字表示,比如邮政编码,但我们不应假设数字类别一定是有序的,或者类别之间的间隔是相等的。
幸运的是,许多分类变量可以视为二分变量,实际上,我们的样本数据包含了sex
的两个类别。只要我们将它们转化为两个数字(例如 0 和 1),这些变量就可以包含在我们的回归模型中。
当像体育项目这样的类别有多个取值时,我们可以为每种体育类型包含一个独立的变量。我们可以为游泳创建一个变量,为举重创建另一个变量,依此类推。游泳的值对游泳者为 1,其他为 0。
由于sex
可能是我们回归模型中有用的解释变量,让我们将女性转换为0
,男性转换为1
。我们可以使用 Incanter 的incanter.core/add-derived-column
函数添加一个包含虚拟变量的派生列。
让我们计算我们的的值,看看是否有所改善:
(defn dummy-mf [sex]
(if (= sex "F")
0.0 1.0))
(defn ex-3-25 []
(let [data (->> (swimmer-data)
(i/add-derived-column "Dummy MF"
["Sex"]
dummy-mf))
x (->> data
(feature-matrix ["Height, cm"
"Age"
"Dummy MF"])
(add-bias))
y (->> (i/$ "Weight" data)
(i/log)
(i/matrix))
beta (normal-equation x y)]
(adj-r-squared beta x y)))
该代码的输出值为0.809
。通过使用身高、年龄和性别特征,我们成功解释了奥运游泳运动员体重超过 80%的差异。
相对力量
此时,提出一个问题可能会很有帮助:哪个特征最能解释观测到的体重:是年龄、性别还是身高?我们可以利用调整后的R²来查看数值变化,但这将要求我们为每个需要测试的变量重新运行回归。
我们不能仅看回归系数的大小,因为它们适用的数据范围差异巨大:身高以厘米为单位,年龄以年为单位,性别作为虚拟变量的取值范围为 0 到 1。
为了比较回归系数的相对贡献,我们可以计算标准化回归系数或贝塔权重。
计算 beta 权重时,我们将每个系数乘以相关独立变量和模型的因变量的标准差比值。这可以通过以下 Clojure 代码实现:
(defn beta-weight [coefs x y]
(let [sdx (map s/sd (i/trans x))
sdy (s/sd y)]
(map #(/ (* %1 %2) sdy) sdx coefs)))
(defn ex-3-26 []
(let [data (->> (swimmer-data)
(i/add-derived-column "Dummy MF"
["Sex"]
dummy-mf))
x (->> data
(feature-matrix ["Height, cm"
"Age"
"Dummy MF"])
(add-bias))
y (->> (i/$ "Weight" data)
(i/log)
(i/matrix))
beta (normal-equation x y)]
(beta-weight beta x y)))
这输出的结果(四舍五入到三位小数)是:
(0.0 0.650 0.058 0.304)
这表明身高是最重要的解释变量,其次是性别,再次是年龄。将其转化为标准化系数告诉我们,身高增加一个标准差时,*均体重增加0.65
个标准差。
共线性
在此时,我们可能会尝试继续向模型中添加特征,试图提高其解释能力。
例如,我们还有一个“出生日期”列,可能会被诱使尝试将其也包括在内。它是一个日期,但我们可以很容易地将其转换为适合回归使用的数字。我们可以通过使用 clj-time
库从出生日期中提取年份来实现这一点:
(defn to-year [str]
(-> (coerce/from-date str)
(time/year)))
(defn ex-3-27 []
(let [data (->> (swimmer-data)
(i/add-derived-column "Dummy MF"
["Sex"]
dummy-mf)
(i/add-derived-column "Year of birth"
["Date of birth"]
to-year))
x (->> data
(feature-matrix ["Height, cm"
"Age"
"Dummy MF"
"Year of birth"])
(add-bias))
y (->> (i/$ "Weight" data)
(i/log)
(i/matrix))
beta (normal-equation x y)]
(beta-weight beta x y)))
;; (-0.0 0.650 0.096 0.304 0.038)
新的“出生年份”特征的 beta 权重只有 0.038
,低于我们之前计算的年龄特征的权重。然而,年龄特征的年龄权重现在显示为 0.096
。自从我们将“出生年份”作为特征添加进来后,它的相对重要性已增加超过 65%。新特征的加入改变了现有特征的重要性,这表明我们遇到了问题。
通过添加额外的“出生年份”参数,我们不小心打破了回归估计器的一个规则。让我们来看看为什么:
(defn ex-3-28 []
(let [data (->> (swimmer-data)
(i/add-derived-column "Year of birth"
["Date of birth"]
to-year))
x (->> (i/$ "Age" data)
(map (jitter 0.5)))
y (i/$ "Year of birth" data)]
(-> (c/scatter-plot x y
:x-label "Age"
:y-label "Year of birth")
(i/view))))
下图显示了游泳运动员的年龄(带有抖动)与他们的出生年份的散点图。正如你所预期的,这两个变量之间的相关性非常高:
这两个特征高度相关,以至于算法无法确定哪一个更能解释 y 的变化。这是我们处理多元线性回归时的一个不良问题,称为共线性。
多重共线性
为了使多元回归产生最佳的系数估计,数据必须符合简单回归的相同假设,再加上一个额外的假设——没有完美的多重共线性。这意味着独立变量之间不应存在完全线性相关性。
注意
实际中,独立变量常常以某种方式存在共线性。例如,考虑年龄和身高,或者性别和身高,它们之间本身就存在相关性。只有当这种情况变得极端时,才可能导致严重的系数错误。
如果自变量实际上并不独立,那么线性回归就无法确定每个自变量的相对贡献。如果两个特征强烈相关,始终一起变化,那么算法怎么区分它们的相对重要性呢?因此,回归系数的估计可能会有较大的方差,并且标准误差较高。
我们已经看到高多重共线性的一个症状:当自变量被添加或从方程中移除时,回归系数发生显著变化。另一个症状是,当一个特定的自变量在多重回归中没有显著系数时,但使用相同自变量的简单回归模型却有较大的R²值。
虽然这些迹象提供了多重共线性的线索,但要确认,我们必须直接查看自变量之间的相互关系。一种确定相互关系的方法是检查各自变量之间的相关性,寻找相关系数为 0.8 或以上的情况。虽然这种简单的方法通常有效,但它可能未能考虑到自变量与其他变量联合的线性关系。
评估多重共线性最可靠的方法是将每个自变量与所有其他自变量进行回归。当这些方程中的任何一个R²接* 1.0 时,就说明存在高多重共线性。事实上,这些R²中的最大值可以作为存在的多重共线性程度的指标。
一旦识别出多重共线性,有几种方法可以处理:
-
增加样本量。更多的数据可以产生更精确的参数估计,标准误差也会更小。
-
将多个特征合并为一个。如果你有多个特征测量的是基本相同的属性,找出一种方法将它们统一成一个特征。
-
舍弃有问题的变量。
-
限制预测方程。共线性会影响模型的系数,但结果仍可能对数据有良好的拟合。
由于年龄和出生年份基本包含相同的信息,我们不妨舍弃其中一个。我们可以通过计算每个特征与因变量的双变量回归,轻松看出哪个特征具有更多的解释力。
“年龄”R² = 0.1049,而“出生年份”R² = 0.1050。
正如预期的那样,这两个特征几乎没有区别,都大约解释了体重方差的 10%。由于出生年份略微解释了更多的方差,我们将保留它并舍弃年龄特征。
预测
最终,我们得出了线性回归的一个重要应用:预测。我们已经训练了一个能够根据奥运游泳运动员的身高、性别和出生年份数据预测体重的模型。
Mark Spitz 是九届奥运游泳冠军,他在 1972 年奥运会上获得了七枚金牌。他出生于 1950 年,根据他的维基百科页面,他身高 183 厘米,体重 73 公斤。让我们看看我们的模型预测他的体重是多少。
我们的多重回归模型要求这些值以矩阵形式呈现。每个参数需要按模型学习特征的顺序提供,以便正确地应用系数。在偏置项之后,我们的特征向量需要包含身高、性别和出生年份,并且这些单位与我们的模型训练时保持一致:
我们的β矩阵包含了这些特征的系数:
我们模型的预测将是每行β系数和特征x的乘积之和:
由于矩阵乘法是通过分别加总每个矩阵的行和列的乘积来生成每个元素,因此生成我们的结果和将β的转置与x[spitz]向量相乘一样简单。
请记住,结果矩阵的维度将是第一个矩阵的行数和第二个矩阵的列数:
是一个
矩阵和一个
矩阵的乘积。结果是一个
矩阵:
用代码计算这个非常简单:
(defn predict [coefs x]
(-> (i/trans coefs)
(i/mmult x)
(first)))
我们调用first
来返回矩阵中的第一个(也是唯一的)元素,而不是整个矩阵:
(defn ex-3-29 []
(let [data (->> (swimmer-data)
(i/add-derived-column "Dummy MF"
["Sex"]
dummy-mf)
(i/add-derived-column "Year of birth"
["Date of birth"]
to-year))
x (->> data
(feature-matrix ["Height, cm"
"Dummy MF"
"Year of birth"])
(add-bias))
y (->> (i/$ "Weight" data)
(i/log)
(i/matrix))
beta (normal-equation x y)
xspitz (i/matrix [1.0 183 1 1950])]
(i/exp (predict beta xspitz))))
这返回了84.21
,对应于预期体重为 84.21 公斤。这比 Mark Spitz 报告的体重 73 公斤要重得多。我们的模型似乎表现得不太好。
预测的置信区间
我们之前计算了总体参数的置信区间。也可以为一个特定的预测构建置信区间,称为预测区间。预测区间通过提供一个最小值和最大值,量化了预测中的不确定性,表示真实值将在一定概率下落在这两个值之间。
的预测区间比总体参数如µ(均值)的置信区间更宽。这是因为置信区间仅需考虑我们在估计均值时的不确定性,而预测区间则必须考虑* y*的方差与均值的偏差。
上一张图片展示了外部预测区间与内部置信区间之间的关系。我们可以使用以下公式计算预测区间:
在这里,是预测值,正负区间。我们使用的是t-分布,其中自由度为
,即样本大小减去参数数量。这与我们之前为F检验计算的自由度相同。尽管公式看起来可能令人生畏,但实际上它可以相对容易地转化为以下代码示例,这段代码计算了 95%的预测区间:
(defn prediction-interval [x y a]
(let [xtx (i/mmult (i/trans x) x)
xtxi (i/solve xtx)
xty (i/mmult (i/trans x) y)
coefs (i/mmult xtxi xty)
fitted (i/mmult x coefs)
resid (i/minus y fitted)
rss (i/sum-of-squares resid)
n (i/nrow y)
p (i/ncol x)
dfe (- n p)
mse (/ ssr dfe)
se-y (first (i/mmult (i/trans a) xtxi a))
t-stat (i/sqrt (* mse (+ 1 se-y)))]
(* (s/quantile-t 0.975 :df dfe) t-stat)))
由于t-统计量是由误差的自由度来参数化的,因此它考虑了模型中的不确定性。
如果我们想计算均值的置信区间而不是预测区间,只需在计算t-stat
时省略将 1 加到se-y
这一操作。
上述代码可以用来生成以下图表,展示了预测区间如何随着自变量的值变化:
在前面的图表中,基于样本大小为五的模型显示了当我们离均值身高越来越远时,95%预测区间如何增大。将之前的公式应用于马克·斯皮茨,得到如下结果:
(defn ex-3-30 []
(let [data (->> (swimmer-data)
(i/add-derived-column "Dummy MF"
["Sex"]
dummy-mf)
(i/add-derived-column "Year of birth"
["Date of birth"]
to-year))
x (->> data
(feature-matrix ["Height, cm"
"Dummy MF"
"Year of birth"])
(add-bias))
y (->> (i/$ "Weight" data)
(i/log)
(i/matrix))
xspitz (i/matrix [1.0 183 1 1950])]
(i/exp (prediction-interval x y xspitz))))
这返回的体重范围是从 72.7 千克到 97.4 千克。这个范围恰好包括了马克的体重 73 千克,因此我们的预测在 95%的预测区间内。尽管如此,这个预测结果仍然非常接*区间的边界。
模型范围
马克·斯皮茨出生于 1950 年,比 2012 年奥运会中最年长的游泳选手还要早几十年。试图用马克的出生年份来预测他的体重,我们就是在尝试超出训练数据的范围进行外推。这超出了我们模型的范围。
还有第二个问题。我们的数据完全基于目前在国际标准比赛中竞争的游泳选手,而马克已经多年没有参加比赛。换句话说,马克现在不属于我们训练模型时的样本群体。为了解决这两个问题,我们需要查找 1979 年马克作为比赛游泳选手的详细资料。
根据www.topendsports.com/athletes/swimming/spitz-mark.htm
,1972 年,22 岁的马克·斯皮茨身高 185 厘米,体重 79 千克。
注意
选择正确的特征是从任何预测算法中获得良好结果的最重要前提之一。
你应该在选择特征时,不仅依据其预测能力,还要考虑它们与所建模领域的相关性。
最终模型
尽管它的R²稍低,但让我们将模型重新训练,将年龄替代出生年份作为特征。这将使我们能够轻松预测过去和未来未见数据的体重,因为它更贴*我们怀疑与体重有因果关系的变量。
这给出了大约β的值:
我们在 1972 年比赛中的马可特征是:
我们可以使用以下代码预测他的竞争体重:
(defn ex-3-32 []
(let [data (->> (swimmer-data)
(i/add-derived-column "Dummy MF"
["Sex"]
dummy-mf))
x (->> data
(feature-matrix ["Height, cm"
"Dummy MF"
"Age"])
(add-bias))
y (->> (i/$ "Weight" data)
(i/log)
(i/matrix))
beta (normal-equation x y)
xspitz (i/matrix [1.0 185 1 22])]
(i/exp (predict beta xspitz))))
这返回78.47
,对应于 78.47 公斤的预测值。这个值现在非常接*马可的真实比赛体重 79 公斤。
总结
在本章中,我们学习了如何确定两个或多个变量是否存在线性关系。我们看到了如何用r来表达它们之间的相关强度,以及如何用R²和来衡量线性模型解释方差的效果。我们还进行了假设检验并计算了置信区间,以推断相关性的真实总体参数范围,
。
在建立了变量之间的相关性后,我们使用普通最小二乘回归和简单的 Clojure 函数构建了一个预测模型。然后,我们使用 Incanter 的矩阵功能和正态方程推广了我们的方法。这个简单的模型通过确定从样本数据推断出的模型参数β来展示机器学习的原理,这些参数可以用来进行预测。我们的模型能够预测一个新运动员的预期体重,并且该预测值完全落在真实值的预测区间内。
在下一章中,我们将看到如何使用类似的技术将数据分类为离散类别。我们将展示多种与分类相关的不同方法,并介绍一种适用于多种机器学习模型的非常通用的参数优化技术,包括线性回归。
第四章 分类
“世人皆知,一个拥有财富的单身汉,一定渴望拥有一位妻子。” | ||
---|---|---|
--简·奥斯汀,《傲慢与偏见》 |
在上一章,我们学习了如何使用线性回归进行数值预测。我们建立的模型能够学习奥林匹克游泳运动员的特征与体重之间的关系,并且我们能够利用这个模型为新的游泳运动员预测体重。与所有回归技术一样,我们的输出是一个数值。
然而,并不是所有的预测都需要数值解答——有时我们希望预测的是项目。例如,我们可能希望预测选民在选举中支持哪位候选人。或者我们可能想知道顾客可能购买哪一款产品。在这些情况下,结果是从若干个离散的选项中做出的选择。我们称这些选项为类别,而我们将在本章构建的模型是分类器。
我们将学习几种不同类型的分类器,并比较它们在一个样本数据集上的表现——泰坦尼克号的乘客名单。预测和分类与概率论和信息理论紧密相关,因此我们也将更详细地讨论这些内容。我们将从衡量不同组之间的相对概率开始,然后转向对这些组进行统计显著性测试。
关于数据
本章将使用关于泰坦尼克号乘客的数据,泰坦尼克号在 1912 年首航时因撞上冰山而沉没。乘客的生还率受多种因素的强烈影响,包括舱位和性别。
该数据集来自于由 Michael A. Findlay 精心编制的一个数据集。如需了解有关数据来源的更多信息,包括原始来源的链接,请参阅本书的维基页面:wiki.clojuredatascience.com
。
注意
本章的示例代码可以从 Packt Publishing 的网站或github.com/clojuredatascience/ch4-classification
获取。
数据量足够小,因此它与源代码一起包含在数据目录中。
检查数据
在上一章中,我们遇到了类别变量,例如运动员数据集中的二元变量“性别”。该数据集还包含许多其他类别变量,包括“运动”、“项目”和“国家”。
让我们来看看泰坦尼克号数据集(使用clojure.java.io
库来访问文件资源,并使用incanter.io
库来读取数据):
(defn load-data [file]
(-> (io/resource file)
(str)
(iio/read-dataset :delim \tab :header true)))
(defn ex-4-1 []
(i/view (load-data :titanic)))
上述代码生成了以下表格:
Titanic 数据集也包含类别变量。例如,:sex、:pclass(乘客等级)和:embarked(表示登船港口的字母)。这些都是字符串值,取值如 female、first 和 C,但是类别不一定总是字符串值。像 :ticket、:boat 和 :body 这样的列也可以视为包含类别变量。尽管它们具有数字值,但它们仅仅是被应用于事物的标签。
注意
类别变量是指只能取离散值的变量。这与连续变量不同,后者可以在其范围内取任何值。
其他代表计数的数字并不那么容易定义。字段 :sibsp 报告乘客随行的伙伴(配偶或兄弟姐妹)数量。这些是计数,单位是人。但它们也可以很容易地表示为标签,其中 0 代表“没有伙伴的乘客”,1 代表“有一个伙伴的乘客”,以此类推。标签的集合很小,因此该字段作为数字的表示主要是为了方便。换句话说,我们可以选择将 :sibsp(以及 :parch —— 计数相关父母和孩子)表示为类别特征或数值特征。
由于类别变量在数值轴上没有意义,我们无法绘制一张图表来显示这些数字之间的关系。然而,我们可以构建一个频率表,展示每个组中乘客的计数是如何分布的。由于有两组两个变量,所以总共有四个组。
数据可以使用 Incanter 核心的 $rollup
函数进行汇总:
(defn frequency-table [sum-column group-columns dataset]
(->> (i/$ group-columns dataset)
(i/add-column sum-column (repeat 1))
(i/$rollup :sum sum-column group-columns)))
(defn ex-4-2 []
(->> (load-data "titanic.tsv")
(frequency-table :count [:sex :survived])))
Incanter 的 $rollup
要求我们提供三个参数——一个函数用于“汇总”一组行,一个要汇总的列,以及定义感兴趣组的唯一值的列。任何将序列减少为单一值的函数都可以作为汇总函数,但有些函数非常常见,我们可以使用关键字 :min
、:max
、:sum
、:count
和 :mean
来代替。
该示例生成了如下表格:
| :survived | :sex | :count |
|-----------+--------+--------|
| n | male | 682 |
| n | female | 127 |
| y | male | 161 |
| y | female | 339 |
这张图表表示了乘客在各个组别中的频率,例如“死亡男性”、“幸存女性”等。对于这样的频率计数,有几种方式可以理解;我们从最常见的一种开始。
与相对风险和几率的比较
上面的 Incanter 数据集是我们数据的一个易于理解的表示,但为了提取每个组的单独数据,我们需要将数据存储在更易于访问的数据结构中。让我们写一个函数,将数据集转换为一系列嵌套的映射:
(defn frequency-map [sum-column group-cols dataset]
(let [f (fn [freq-map row]
(let [groups (map row group-cols)]
(->> (get row sum-column)
(assoc-in freq-map groups))))]
(->> (frequency-table sum-column group-cols dataset)
(:rows)
(reduce f {}))))
例如,我们可以使用 frequency-map
函数,如下所示,计算 :sex
和 :survived
的嵌套映射:
(defn ex-4-3 []
(->> (load-data "titanic.tsv")
(frequency-map :count [:sex :survived])))
;; => {"female" {"y" 339, "n" 127}, "male" {"y" 161, "n" 682}}
更一般地,给定任何数据集和列序列,这将使我们更容易提取出我们感兴趣的计数。我们将比较男性和女性的生存率,所以我们使用 Clojure 的 get-in
函数来提取男性和女性的死亡人数,以及男性和女性的总人数:
(defn fatalities-by-sex [dataset]
(let [totals (frequency-map :count [:sex] dataset)
groups (frequency-map :count [:sex :survived] dataset)]
{:male (/ (get-in groups ["male" "n"])
(get totals "male"))
:female (/ (get-in groups ["female" "n"])
(get totals "female"))}))
(defn ex-4-4 []
(-> (load-data "titanic.tsv")
(fatalities-by-sex)))
;; {:male 682/843, :female 127/466}
从这些数字中,我们可以计算简单的比率。相对风险是两组中事件发生概率的比值:
其中,P(event) 是事件发生的概率。作为男性,在泰坦尼克号上遇难的风险是682除以843;作为女性,在泰坦尼克号上遇难的风险是127除以466:
(defn relative-risk [p1 p2]
(float (/ p1 p2)))
(defn ex-4-5 []
(let [proportions (-> (load-data "titanic.tsv")
(fatalities-by-sex))]
(relative-risk (get proportions :male)
(get proportions :female))))
;; 2.9685
换句话说,如果你是男性,那么在泰坦尼克号上遇难的风险几乎是女性的三倍。相对风险通常用于医疗领域,用来展示某个因素如何影响一个人患病的几率。相对风险为一意味着两组之间的风险没有差异。
相比之下,赔率比可以是正数或负数,衡量的是在某一组中,某一特征的概率增加的程度。和任何相关性一样,这并不意味着存在因果关系。两个属性当然可以通过第三个因素——它们的共同原因——关联起来:
作为男性遇难的赔率是682:161,作为女性遇难的赔率是127:339。赔率比就是这两者的比值:
(defn odds-ratio [p1 p2]
(float
(/ (* p1 (- 1 p2))
(* p2 (- 1 p1)))))
(defn ex-4-6 []
(let [proportions (-> (load-data "titanic.tsv")
(fatalities-by-sex))]
(odds-ratio (get proportions :male)
(get proportions :female))))
;; 11.3072
这个例子展示了赔率比如何对相对位置的陈述敏感,并且可能产生更大的数字。
提示
在面对比率时,确保你清楚它们是相对风险比(relative-risk)还是赔率比(odds ratio)。虽然这两种方法看起来相似,但它们的输出结果在不同的范围内。
比较相对风险和赔率比的两个公式。在每种情况下,分子是相同的,但对于风险而言,分母是所有女性,而对于赔率比来说,分母是幸存的女性。
比例的标准误差
很明显,女性在泰坦尼克号上幸存的比例远高于男性。但正如我们在第二章 推断 中遇到的停留时间差异一样,我们应该问自己,这些差异是否可能仅仅是由于偶然因素导致的。
我们在前几章中已经看到,如何根据样本的标准误差构建统计量的置信区间。标准误差是基于样本的方差计算的,但比例的方差是多少呢?无论我们取多少样本,生成的比例只有一个——即总体样本中的比例。
显然,比例仍然会受到某种程度的方差影响。当我们将一枚公*的硬币抛掷 10 次时,我们预期大约会得到五次正面,但并不是不可能出现连续十次正面。
使用引导法的估算
在第二章,推断中,我们学习了引导统计量,如均值,并看到了引导法如何通过模拟估算参数。让我们使用引导法来估算泰坦尼克号女性乘客幸存比例的标准误差。
我们可以将 466 名女性乘客表示为一个零和一的序列。零可以表示一位遇难的乘客,一则表示一位幸存的乘客。这是一种方便的表示方式,因为这意味着整个序列的和等于幸存乘客的总数。通过从这个由 466 个零和一组成的序列中反复随机抽取 466 个元素,并计算每次的总和,我们可以估算比例的方差:
(defn ex-4-7 []
(let [passengers (concat (repeat 127 0)
(repeat 339 1))
bootstrap (s/bootstrap passengers i/sum :size 10000)]
(-> (c/histogram bootstrap
:x-label "Female Survivors"
:nbins 20)
(i/view))))
上面的代码生成了以下直方图:
直方图似乎显示了一个均值为 339 的正态分布——即测得的女性幸存者数量。这个分布的标准差是采样幸存者的标准误差,我们可以通过简单地从引导样本中计算得出:
(defn ex-4-8 []
(-> (concat (repeat 127 0)
(repeat 339 1))
(s/bootstrap i/sum :size 10000)
(s/sd)))
;; 9.57
你的标准差可能会略有不同,具体取决于引导样本中的偶然变化。不过,它应该非常接*。
标准差的单位是人——女性乘客——因此,为了计算比例的标准误差,我们必须将其除以样本中的总乘客人数,即 466 人。这样得到的比例标准误差是 0.021。
二项分布
前面的直方图看起来非常像正态分布,但实际上它是一个二项分布。这两种分布非常相似,但二项分布用于建模我们希望确定二元事件发生次数的情况。
让我们将二项分布和正态分布同时绘制在直方图上,以便比较它们的差异:
(defn ex-4-9 []
(let [passengers (concat (repeat 127 0)
(repeat 339 1))
bootstrap (s/bootstrap passengers i/sum :size 10000)
binomial (fn [x]
(s/pdf-binomial x :size 466 :prob (/ 339 466)))
normal (fn [x]
(s/pdf-normal x :mean 339 :sd 9.57))]
(-> (c/histogram bootstrap
:x-label "Female Survivors"
:series-label "Bootstrap"
:nbins 20
:density true
:legend true)
(c/add-function binomial 300 380
:series-label "Biomial")
(c/add-function normal 300 380
:series-label "Normal")
(i/view))))
上面的代码生成了以下图表:
请注意,在前面的图表中,与二项分布对应的线是锯齿形的——它表示的是离散的计数,而不是像正态分布那样的连续值。
比例的标准误差公式
我们已经通过经验计算出了标准误差,并发现其值为 0.021,仅使用了女性幸存者的比例和女性乘客的总数。尽管看到比例的标准误差实际上测量的内容很有启发性,但有一个公式可以让我们一步到位地得到这个结果:
将女性幸存者的计数代入公式后,我们得到如下结果:
幸运的是,这个数字与我们通过自助法(bootstrapping)计算得出的标准误差非常接*。当然,这不是完全一样的,因为我们的自助法计算有自己的抽样误差。
(defn standard-error-proportion [p n]
(-> (- 1 p)
(* p)
(/ n)
(i/sqrt)))
(defn ex-4-10 []
(let [survived (->> (load-data "titanic.tsv")
(frequency-map :count [:sex :survived]))
n (reduce + (vals (get survived "female")))
p (/ (get-in survived ["female" "y"]) n)]
(se-proportion p n)))
;; 0.0206
比例的标准误差公式为我们提供了一个重要的见解——当p接* 0.5 时,p(1 - p)的值最大。这意味着,当比例接*一半时,比例的标准误差最大。
如果这让你感到惊讶,可以考虑这一点——当比例为 50%时,样本中的变异性最大。就像公*的掷硬币一样,我们无法预测下一个值会是什么。当样本中的比例增大(或减小)时,数据变得越来越同质。因此,变异性减少,从而标准误差也相应减少。
显著性检验比例
让我们回到问题:男女死亡率的差异是否仅仅是偶然的结果。如同第二章中所述,推断,我们的z检验实际上是将比例的差异除以合并的标准误差:
在前面的公式中,p[1]表示幸存女性的比例,即p339/466 = 0.73。而p[2]表示幸存男性的比例,即p161/843 = 0.19。
要计算z统计量,我们需要合并两个比例的标准误差。我们的比例分别衡量了男性和女性的生还率,因此合并的标准误差实际上就是男性和女性的标准误差之和,或整体的总生还率,计算公式如下:
将数值代入z统计量的公式:
使用z分数意味着我们将使用正态分布查找p-值:
(defn ex-4-11 []
(let [dataset (load-data "titanic.tsv")
proportions (fatalities-by-sex dataset)
survived (frequency-map :count [:survived] dataset)
total (reduce + (vals survived))
pooled (/ (get survived "n") total)
p-diff (- (get proportions :male)
(get proportions :female))
z-stat (/ p-diff (se-proportion pooled total))]
(- 1 (s/cdf-normal (i/abs z-stat)))))
;; 0.0
由于我们进行的是单尾检验,p-值是z分数小于 39.95 的概率。答案为零,表示这是一个非常非常显著的结果。这使我们能够拒绝零假设,并得出结论:男女之间的生还率差异显然不仅仅是偶然的结果。
调整大样本的标准误差
你可能会好奇,为什么我们要讨论标准误差。我们关于泰坦尼克号乘客的数据并不是来自一个更大人群的样本,它就是整个样本。泰坦尼克号只有一艘,而且只有一次命运多舛的航行。
虽然从某种意义上讲这是正确的,但泰坦尼克号灾难发生的方式有很多种。如果没有遵循“妇女和儿童优先”的指示,或者更普遍地遵循了这些指示,结果可能会有所不同。如果每个人都有足够的救生艇,或者疏散过程更顺利,那么这些因素也会体现在结果中。
标准误差和显著性检验使我们能够将灾难视为无数潜在相似灾难之一,并确定观察到的差异是否可能是系统性的,还是纯粹的巧合。
话虽如此,有时我们更关心的是我们对样本是否能够代表有限的、量化的人群的信心。随着样本开始测量总体的 10%以上时,我们可以通过调整标准误差来降低不确定性:
这可以用 Clojure 编写如下:
(defn se-large-proportion [p n N]
(* (se-proportion p n)
(i/sqrt (/ (- N n)
(- n 1)))))
N 是总体人口的大小。当样本量相对于总体人口的大小增加时,(N - n) 会趋*于零。如果你采样了整个总体,那么无论比例差异多么微小,都会被判断为显著的。
卡方检验的多重显著性检验
并非所有类别都是二元的(例如男性和女性,生还和遇难)。虽然我们期望类别变量有有限数量的类别,但对于特定属性的类别数并没有硬性上限。
我们可以使用其他类别变量来区分泰坦尼克号上的乘客,例如他们乘坐的舱位。泰坦尼克号有三个舱位等级,而我们在本章开头构建的 frequency-table
函数已经能够处理多个舱位。
(defn ex-4-12 []
(->> (load-data "titanic.tsv")
(frequency-table :count [:survived :pclass])))
这段代码生成了以下的频率表:
| :pclass | :survived | :count |
|---------+-----------+--------|
| third | y | 181 |
| third | n | 528 |
| second | y | 119 |
| second | n | 158 |
| first | n | 123 |
| first | y | 200 |
这三个舱位为我们提供了一个额外的方式来分析生还率数据。随着舱位数量的增加,在频率表中识别模式变得更加困难,因此我们可以将其可视化。
可视化类别
尽管饼图最初是为了表示比例而设计的,但通常不是表示部分与整体关系的好方式。人们很难在视觉上比较圆形切片的面积。将数量线性表示,如堆叠柱状图,几乎总是更好的方法。不仅面积更容易解释,而且它们更容易并排比较。
我们可以将数据可视化为堆叠柱状图:
(defn ex-4-13 []
(let [data (->> (load-data "titanic.tsv")
(frequency-table :count [:survived :pclass]))]
(-> (c/stacked-bar-chart :pclass :count
:group-by :survived
:legend true
:x-label "Class"
:y-label "Passengers"
:data data)
(i/view))))
上面的代码生成了以下图表:
数据清楚地显示了乘客死亡人数和比例之间的差异,尤其是在头等舱和三等舱之间最为明显。我们想确定这种差异是否显著。
我们可以在每对比例之间执行z检验,但正如我们在第二章学到的那样,这很可能导致Ⅰ型错误,并导致我们在事实上没有显著结果时找到显著结果。
多类别显著性测试的问题似乎需要进行F检验,但F检验基于某些连续变量在组内和组间方差的比率。因此,我们需要的是一个类似的测试,它只关心组之间的相对比例。这是X²检验的基础。
卡方检验
发音为kai square,X²检验是应用于分类数据集的统计检验,用于评估观察到的不同类别比例之间差异可能是由于偶然因素引起的概率。
因此,在进行X²检验时,我们的零假设是组间观察到的比例差异仅仅是由于偶然变异所导致的结果。我们可以将其视为两个分类变量之间的独立性检验。如果类别A是乘客等级,类别B是是否存活,则零假设是乘客等级和生存率彼此独立。备择假设是这些类别不独立——乘客等级和生存率在某种程度上相关。
X²统计量是通过将样本的观察频率与根据独立性假设计算的频率表进行比较而计算的。这个频率表是估计数据如果类别独立会是什么样子的。我们可以通过以下方式计算假设独立的频率表,使用行、列和总计:
生存 | 未生存 | 总计 | |
---|---|---|---|
一等舱 | 323500/1309 = 123.4* | 323809/1309 = 199.6* | 323 |
二等舱 | 277500/1309 = 105.8* | 277809/1309 = 171.2* | 277 |
三等舱 | 709500/1309 = 270.8* | 709809/1309 = 438.2* | 709 |
总计 | 500 | 809 | 1,309 |
一个简单的公式仅使用每行和每列的总数计算每个单元格的值,并假设在单元格之间有均匀分布。这是我们的预期频率表。
(defn expected-frequencies [data]
(let [as (vals (frequency-map :count [:survived] data))
bs (vals (frequency-map :count [:pclass] data))
total (-> data :rows count)]
(for [a as
b bs]
(* a (/ b total)))))
(defn ex-4-14 []
(-> (load-data "titanic.tsv")
(expected-frequencies)))
;; => (354500/1309 138500/1309 9500/77 573581/1309 224093/1309 15371/77)
为了证明不同乘客等级的生存率之间的统计显著差异,我们需要表明假设独立和观察频率之间的差异不太可能仅仅是由于偶然因素引起的。
卡方统计量
X²统计量简单地衡量了实际频率与假设独立情况下计算的频率之间的差异:
F[ij] 是假设类别 i 和 j 独立时的预期频率,而 f[ij] 是类别 i 和 j 的观察频率。因此,我们需要获取数据的观察频率。我们可以在 Clojure 中如下计算:
(defn observed-frequencies [data]
(let [as (->> (i/$rollup :sum :count :survived data)
(summary :count [:survived]))
bs (->> (i/$rollup :sum :count :pclass data)
(summary :count [:pclass]))
actual (summary :count [:survived :pclass] data)]
(for [a (keys as)
b (keys bs)]
(get-in actual [a b]))))
与之前的 expected-frequencies
函数一样,observed-frequencies
函数返回每一对类别组合的频率计数序列。
(defn ex-4-15 []
(-> (load-data "titanic.tsv")
(observed-frequencies)))
;; (200 119 181 123 158 528)
这个序列——以及前面示例中预期值的序列——为我们提供了计算 X² 统计量所需的所有信息:
(defn chisq-stat [observed expected]
(let [f (fn [observed expected]
(/ (i/sq (- observed expected)) expected))]
(reduce + (map f observed expected))))
(defn ex-4-16 []
(let [data (load-data "titanic.tsv")
observed (observed-frequencies data)
expected (expected-frequencies data)]
(float (chisq-stat observed expected))))
;; 127.86
现在我们有了检验统计量,我们需要查找相关的分布,以确定结果是否显著。毫不奇怪,我们参照的分布是 X² 分布。
卡方检验
X² 分布由一个自由度参数化:每个类别计数减去 1 的乘积:
这里,a 是属性 A 的类别数量,b 是属性 B 的类别数量。对于我们的泰坦尼克号数据,a 是 3,b 是 2,所以我们的自由度参数是 2。
我们的 X² 检验只需要将我们的 X² 统计量与 X² 累积分布函数(CDF)进行对比。我们现在来做这个:
(defn ex-4-17 []
(let [data (load-data "titanic.tsv")
observed (observed-frequencies data)
expected (expected-frequencies data)
x2-stat (chisq-stat observed expected)]
(s/cdf-chisq x2-stat :df 2 :lower-tail? false)))
;; 1.721E-28
这是一个极其小的数字,接*于零,可以认为它没有差别,因此我们可以在任何显著性水*下放心地拒绝原假设。换句话说,我们可以完全确信观察到的差异不是偶然抽样误差的结果。
尽管手动进行 X² 检验很有用,但 Incanter stats 命名空间有一个函数 chisq-test
,可以一步完成 X² 检验。使用这个函数,我们只需要将原始的观察表作为矩阵提供给它:
(defn ex-4-18 []
(let [table (->> (load-data "titanic.tsv")
(frequency-table :count [:pclass :survived])
(i/$order [:survived :pclass] :asc))
frequencies (i/$ :count table)
matrix (i/matrix frequencies 3)]
(println "Observed:" table)
(println "Frequencies:" frequencies)
(println "Observations:" matrix)
(println "Chi-Squared test:")
(-> (s/chisq-test :table matrix)
(clojure.pprint/pprint))))
在前面的代码中,我们从泰坦尼克号数据中计算了一个频率表,并使用 i/$order
对其内容进行排序,这样我们就得到了像这样的表格:
| :survived | :pclass | :count |
|-----------+---------+--------|
| n | first | 123 |
| n | second | 158 |
| n | third | 528 |
| y | first | 200 |
| y | second | 119 |
| y | third | 181 |
我们取计数列,并使用 (i/matrix frequencies 3)
将其转换为一个包含三列的矩阵:
A 2x3 matrix
-------------
1.23e+02 1.58e+02 5.28e+02
2.00e+02 1.19e+02 1.81e+02
这个矩阵是 Incanter 的 s/chisq-test
函数所需的唯一输入。运行示例,你将看到响应是一个包含键 :X-sq
(X² 统计量)和 :p-value
(检验结果)等的映射。
我们已经确定类别 "是否幸存" 和 "性别" 之间的关系绝对不是独立的。这类似于在上一章中发现变量——身高、性别和体重——之间的相关性。
如今,接下来的步骤是利用变量之间的依赖关系进行预测。在上一章中,我们的输出是一个预测数字——权重,而在这一章中,我们的输出将是一个类别——关于乘客是否幸存的预测。根据其他属性将项目分配到其预期类别的过程就是分类。
使用逻辑回归进行分类
在上一章,我们看到线性回归如何从输入向量x和系数向量β中生成预测值ŷ:
在这里,ŷ可以是任何实数。逻辑回归的过程非常类似,但它调整预测值,确保结果仅在零和一之间。
零和一代表两种不同的类别。这个变化非常简单;我们只是将预测值包装在一个函数g中,该函数将输出限制在零和一之间:
其中g被称为Sigmoid 函数。这个看似微小的变化足以将线性回归转变为逻辑回归,并将实数值预测转化为类别。
Sigmoid 函数
Sigmoid 函数也被称为逻辑函数,如图所示:
对于正输入,逻辑函数快速上升至一,而对于负输入,它会迅速下降至零。这些输出对应着预测的类别。对于接*零的值,逻辑函数返回接*0.5的值,这意味着对正确输出类别的判断不确定性增大。
结合我们已经看到的公式,得出以下完整的逻辑假设定义:
与线性回归一样,参数向量β包含我们要学习的系数,x是我们的输入特征向量。我们可以用 Clojure 表达这一点,使用以下的高阶函数。给定一个系数向量,这个函数返回一个函数,该函数将为给定的x计算ŷ:
(defn sigmoid-function [coefs]
(let [bt (i/trans coefs)
z (fn [x] (- (first (i/mmult bt x))))]
(fn [x]
(/ 1
(+ 1
(i/exp (z x)))))))
如果逻辑函数给定的β为[0]
,那么该特征将被认为没有任何预测能力。对于任何输入x,该函数将输出0.5
,对应完全的不确定性:
(let [f (sigmoid-function [0])]
(f [1])
;; => 0.5
(f [-1])
;; => 0.5
(f [42])
;; => 0.5
)
然而,如果提供了非零的系数,Sigmoid 函数可能会返回0.5
以外的值。正的β会使得给定正的x时,正类的概率增大,而负的β会使得给定正的x时,负类的概率增大。
(let [f (sigmoid-function [0.2])
g (sigmoid-function [-0.2])]
(f [5])
;; => 0.73
(g [5])
;; => 0.27
)
由于大于0.5
的值对应正类,小于0.5
的值对应负类,Sigmoid 函数的输出可以简单地四舍五入到最接*的整数,从而获得输出类别。这样,恰好为0.5
的值会被分类为正类。
现在我们有一个sigmoid-function
可以返回类别预测,我们需要学习参数β,以产生最佳预测ŷ。在前一章中,我们看到了两种计算线性模型系数的方法——使用协方差计算斜率和截距,以及使用矩阵的正规方程。在这两种情况下,方程都能找到一个线性解,以最小化我们模型的最小二乘估计。
*方误差是我们线性模型适合的适当函数,但在分类问题中,类别只在零和一之间测量,它不能很好地转化为确定我们预测不正确程度的方法。我们需要一种替代方法。
逻辑回归成本函数
与线性回归类似,逻辑回归算法必须从数据中学习。cost
函数是让算法知道其表现如何的一种方式,好或坏。
以下是逻辑回归的cost
函数,它根据输出类别是否应为零或一施加不同的成本。单个训练示例的成本计算如下:
这对函数捕捉到这样的直觉,即如果ŷ = 0,但y = 1,则模型应受到非常大的惩罚成本。对称地,如果ŷ = 1,而y = 0,则模型也应受到严重惩罚。当模型与数据高度一致时,成本急剧下降至零。
这是单个训练点的成本。要结合个别成本并计算给定系数向量和一组训练数据的总体成本,我们可以简单地取所有训练示例的*均值:
在 Clojure 中可以表示为:
(defn logistic-cost [ys y-hats]
(let [cost (fn [y y-hat]
(if (zero? y)
(- (i/log (- 1 y-hat)))
(- (i/log y-hat))))]
(s/mean (map cost ys y-hats))))
现在我们有一个cost
函数,可以量化我们预测不正确的程度,下一步是利用这些信息来找出更好的预测。最佳分类器将是总体成本最低的分类器,因为根据定义,其预测类将最接*真实类。我们可以通过所谓的梯度下降方法逐步改进我们的成本。
使用梯度下降进行参数优化
成本函数,也称为损失函数,是根据我们的系数计算模型误差的函数。不同的参数将为相同的数据集生成不同的成本,我们可以在图表上可视化成本函数随参数变化的情况。
上述图表显示了两参数模型的成本函数表示。成本在y轴上绘制(较高的值对应较高的成本),两个参数分别在x轴和z轴上绘制。
最佳参数是那些最小化成本函数的参数,对应于被标记为“全局最小值”的点。我们无法提前知道这些参数是什么,但可以进行初步的任意猜测。这些参数对应于点“P”。
梯度下降是一种通过沿着梯度向下迭代改进初始条件的算法,直到找到最小值。当算法无法进一步下降时,最小成本就找到了。此时的参数对应于我们对于最小化成本函数的最佳估计。
带有 Incanter 的梯度下降
Incanter 提供了使用incanter.optimize
命名空间中的minimize
函数运行梯度下降的功能。数学优化是指一系列旨在找到某组约束条件下最佳解决方案的技术的总称。incanter.optimize
命名空间包含了用于计算能最小化或最大化任意函数值的参数的函数。
例如,以下代码在给定初始位置10
的情况下找到f
的最小值。由于f
是x²,产生最小值的输入是0
:
(defn ex-4-19 []
(let [f (fn [[x]]
(i/sq x))
init [10]]
(o/minimize f init)))
实际上,如果你运行这个例子,你应该得到一个非常接*零的答案。然而,由于梯度下降往往只提供*似答案,你不太可能得到准确的零—Incanter 的minimize
函数接受一个容差参数:tol
,默认为 0.00001。如果结果在迭代之间的差异小于这个值,则表示方程已经收敛。该函数还接受一个:max-iter
参数,即在返回答案之前最多执行的步数,不考虑是否已收敛。
凸性
梯度下降并不总能确保找到所有方程的最低成本。例如,结果可能找到所谓的“局部最小值”,它表示初始猜测附*的最低成本,但并不代表问题的最佳整体解决方案。以下图示说明了这一点:
如果初始位置对应于图中标记为C的点,则算法将收敛到局部最小值。梯度下降会找到一个最小值,但这不是最佳的整体解决方案。只有初始猜测位于A到B的范围内,才会收敛到全局最小值。
因此,梯度下降可能会根据其初始化收敛到不同的答案。为了确保找到最优解,梯度下降要求优化的方程必须是凸的。这意味着方程只有一个全局最小值,且没有局部最小值。
例如,sin
函数没有全局最小值。我们计算出的最小值将强烈依赖于我们的初始条件:
(defn ex-4-20 []
(let [f (fn [[x]]
(i/sin x))]
(println (:value (o/minimize f [1])))
(println (:value (o/minimize f [10])))
(println (:value (o/minimize f [100])))))
A 1x1 matrix
-------------
-2.14e+05
A 1x1 matrix
-------------
1.10e+01
A 1x1 matrix
-------------
9.90e+01
幸运的是,逻辑回归是一个凸函数。这意味着无论我们从哪里开始,梯度下降都能确定对应全局最小值的系数值。
使用 Incanter 实现逻辑回归
我们可以通过以下方式使用 Incanter 的 minimize
函数来定义逻辑回归函数:
(defn logistic-regression [ys xs]
(let [cost-fn (fn [coefs]
(let [classify (sigmoid-function coefs)
y-hats (map (comp classify i/trans) xs)]
(logistic-cost ys y-hats)))
init-coefs (repeat (i/ncol xs) 0.0)]
(o/minimize cost-fn init-coefs)))
cost-fn
接受一个系数矩阵。我们使用之前定义的 sigmoid-function
从系数创建分类器,并基于输入数据生成一系列预测值 y-hats
。最后,我们可以基于提供的系数计算并返回 logistic-cost
值。
为了执行逻辑回归,我们通过选择最优参数来最小化逻辑 cost-fn
,并传递给 sigmoid-function
。由于我们必须从某个地方开始,我们的初始系数就是每个参数的 0.0
。
minimize
函数期望接收数字形式的输入。像上一章的运动员数据一样,我们需要将 Titanic 数据转换为特征矩阵,并为类别数据创建虚拟变量。
创建特征矩阵
让我们定义一个函数 add-dummy
,它将为给定的列创建一个虚拟变量。当输入列中的值等于某个特定值时,虚拟列将包含 1
。当输入列中的值不等于该值时,虚拟列将为 0
。
(defn add-dummy [column-name from-column value dataset]
(i/add-derived-column column-name
[from-column]
#(if (= % value) 1 0)
dataset))
这个简单的函数使得将 Titanic 数据转换为特征矩阵变得非常简单:
(defn matrix-dataset []
(->> (load-data "titanic.tsv")
(add-dummy :dummy-survived :survived "y")
(i/add-column :bias (repeat 1.0))
(add-dummy :dummy-mf :sex "male")
(add-dummy :dummy-1 :pclass "first")
(add-dummy :dummy-2 :pclass "second")
(add-dummy :dummy-3 :pclass "third")
(i/$ [:dummy-survived :bias :dummy-mf
:dummy-1 :dummy-2 :dummy-3])
(i/to-matrix)))
我们的输出矩阵将完全由零和一组成。特征矩阵的第一个元素是决定生存的虚拟变量。这是我们的类标签。0
代表死亡,1
代表生存。第二个是 bias
项,始终包含值 1.0
。
在定义了我们的 matrix-dataset
和 logistic-regression
函数后,运行逻辑回归就像这样简单:
(defn ex-4-21 []
(let [data (matrix-dataset)
ys (i/$ 0 data)
xs (i/$ [:not 0] data)]
(logistic-regression ys xs)))
我们为 Incanter 的 i/$
函数提供 0
,以选择矩阵的第一列(类),并使用 [:not 0
] 来选择其他所有项(特征):
;; [0.9308681940090573 -2.5150078795265753 1.1782368822555778
;; 0.29749924127081434 -0.5448679293359383]
如果你运行这个例子,你会发现它返回一个数字向量。这个向量对应于逻辑回归模型系数的最佳估计值。
评估逻辑回归分类器
在前一部分计算的向量包含了我们逻辑回归模型的系数。我们可以通过将这些系数传递给我们的 sigmoid-function
来进行预测,如下所示:
(defn ex-4-22 []
(let [data (matrix-dataset)
ys (i/$ 0 data)
xs (i/$ [:not 0] data)
coefs (logistic-regression ys xs)
classifier (comp logistic-class
(sigmoid-function coefs)
i/trans)]
(println "Observed: " (map int (take 10 ys)))
(println "Predicted:" (map classifier (take 10 xs)))))
;; Observed: (1 1 0 0 0 1 1 0 1 0)
;; Predicted: (1 0 1 0 1 0 1 0 1 0)
你会看到分类器并没有做得完美——它对一些类感到困惑。在前十个结果中,它将四个类预测错误,这只比随机猜测稍好一点。让我们看看在整个数据集中正确识别的类的比例:
(defn ex-4-23 []
(let [data (matrix-dataset)
ys (i/$ 0 data)
xs (i/$ [:not 0] data)
coefs (logistic-regression ys xs)
classifier (comp logistic-class
(sigmoid-function coefs)
i/trans)
y-hats (map classifier xs)]
(frequencies (map = y-hats (map int ys)))))
;; {true 1021, false 288}
在前面的代码中,我们像以前一样训练一个分类器,然后简单地遍历整个数据集,查找预测结果与观察到的类别是否相同。我们使用 Clojure 核心的 frequencies
函数来提供一个简单的计数,统计类别相等的次数。
在 1,309 次预测中,正确预测 1,021 次意味着 78%的正确率。我们的分类器显然比随机猜测要好。
混淆矩阵
虽然正确率是一个简单的计算和理解的度量,但它容易受到分类器系统性地低估或高估某个类别的影响。作为极端例子,考虑一个始终将乘客归类为已死亡的分类器。在我们的 Titanic 数据集中,这样的分类器会显示出 68%的正确率,但在一个大部分乘客都生还的替代数据集中,它的表现将会非常糟糕。
一个 confusion-matrix
函数展示了训练集中的误分类项,按照真正例、真反例、假正例和假反例进行划分。混淆矩阵的每一行代表输入类别,每一列代表模型类别。我们可以在 Clojure 中这样创建它:
(defn confusion-matrix [ys y-hats]
(let [classes (into #{} (concat ys y-hats))
confusion (frequencies (map vector ys y-hats))]
(i/dataset (cons nil classes)
(for [x classes]
(cons x
(for [y classes]
(get confusion [x y])))))))
然后,我们可以像这样在我们的逻辑回归结果上运行混淆矩阵:
(defn ex-4-24 []
(let [data (matrix-dataset)
ys (i/$ 0 data)
xs (i/$ [:not 0] data)
coefs (logistic-regression ys xs)
classifier (comp logistic-class
(sigmoid-function coefs)
i/trans)
y-hats (map classifier xs)]
(confusion-matrix (map int ys) y-hats)))
它返回以下矩阵:
| | 0 | 1 |
|---+-----+-----|
| 0 | 682 | 127 |
| 1 | 161 | 339 |
我们可以看到模型返回了 682
个真正例和 339
个真反例,总计 1,021 次正确预测。一个优秀模型的混淆矩阵将主要集中在对角线上,非对角线位置的数字会小得多。一个完美的分类器将在所有非对角线的单元格中显示零。
kappa 统计量
kappa 统计量可以用来比较两组类别之间的匹配度,以查看它们的相符程度。它比仅仅查看百分比一致性更为稳健,因为该公式旨在考虑某些一致性可能仅仅是由于偶然发生的可能性。
kappa 统计量模型计算每个类别在每个序列中出现的频率,并将其纳入计算中。例如,如果我在每次抛硬币时猜中正反面各 50%的概率,但我总是猜正面,那么 kappa 统计量的值将为零。这是因为一致性并没有超出偶然可能发生的范围。
为了计算 kappa 统计量,我们需要知道两件事:
-
p(a):这是实际观察到的一致性概率。
-
p(e):这是预期一致性的概率。
p(a) 的值是我们之前计算出的 78%的百分比一致性。它是正确的正例和正确的负例的总和除以样本的大小。
为了计算 p(e) 的值,我们需要知道数据中负类的比例,以及我们模型预测的负类比例。数据中负类的比例是 ,即 62%。这是泰坦尼克号灾难中的整体死亡概率。模型中的负类比例可以通过混淆矩阵计算得出,为
,即 64%。
数据和模型可能偶然一致的概率,p(e),是指模型和数据同时为负类的概率 加上数据和模型同时为正类的概率
。因此,随机一致的概率 p(e) 约为 53%。
上述信息就是我们计算 kappa 统计量所需的全部内容:
代入我们刚刚计算的值,得到:
我们可以在 Clojure 中按如下方式计算:
(defn kappa-statistic [ys y-hats]
(let [n (count ys)
pa (/ (count (filter true? (map = ys y-hats))) n)
ey (/ (count (filter zero? ys)) n)
eyh (/ (count (filter zero? y-hats)) n)
pe (+ (* ey eyh)
(* (- 1 ey)
(- 1 eyh)))]
(/ (- pa pe)
(- 1 pe))))
(defn ex-4-25 []
(let [data (matrix-dataset)
ys (i/$ 0 data)
xs (i/$ [:not 0] data)
coefs (logistic-regression ys xs)
classifier (comp logistic-class
(sigmoid-function coefs)
i/trans)
y-hats (map classifier xs)]
(float (kappa-statistic (map int ys) y-hats))))
;; 0.527
Kappa 值的范围在 0 到 1 之间,1 表示两个输出类别完全一致。仅对一个输出类别完全一致时,kappa 值是未定义的——比如如果我每次都猜对抛硬币的结果 100%,但硬币每次都显示正面,那么我们无法知道硬币是否公正。
概率
到目前为止,我们在本书中以不同的方式遇到了概率:作为 p 值、置信区间,以及最*作为逻辑回归的输出,其中结果可以视为输出类别为正类的概率。我们为 kappa 统计量计算的概率是通过将计数加总并除以总数得到的。例如,一致的概率是通过将模型和数据一致的次数除以样本数来计算的。这种计算概率的方法被称为 频率主义,因为它关注的是事件发生的频率。
逻辑回归输出为 1.0
(未四舍五入)表示输入属于正类的确定性;输出为 0.0
表示输入不属于正类的确定性;输出为 0.5
表示对输出类别完全不确定。例如,如果 ŷ = 0.7,则 y = 1 的概率为 70%。我们可以用以下方式表示:
我们说 y-hat 等于在给定 x 和由 β 参数化的情况下,y 等于 1 的概率。这个新符号表示我们的预测 ŷ 是由输入 x 和 β 等信息决定的。这些向量中的值会影响我们对输出概率的计算,从而影响我们对 y 的预测。
频率学派的概率观念的替代方法是贝叶斯观点。贝叶斯的概率观念将先验信念纳入概率计算。为了说明两者的不同,我们再次来看抛硬币的例子。
假设抛硬币 14 次,其中正面朝上出现 10 次。现在要求你赌下两次抛掷是否会出现正面,你会下注吗?
对频率学派来说,硬币连续两次正面朝上的概率是。这略高于 50%,因此下注是有道理的。
贝叶斯学派会以不同的方式来框定问题。假设我们有一个认为硬币是公*的先验信念,那么数据与这一信念的契合程度如何?14 次抛掷的标准误差是 0.12。与
之差除以标准误差约为 1.77,对应的p值大约是 0.08。证据不足以拒绝硬币公*的理论。如果硬币是公*的,那么连续两次出现正面的概率是
,我们很可能会输掉这场赌局。
注意
在 18 世纪,皮埃尔-西蒙·拉普拉斯提出了“太阳明天会升起的概率是多少?”这个问题,旨在说明用概率论来评估陈述的可信度的困难。
贝叶斯的概率观念引出了一个非常有用的定理——贝叶斯定理。
贝叶斯定理
我们在上一节中介绍的逻辑回归方程就是条件概率的一个例子:
我们的预测ŷ的概率由* x 和 β *的值决定。条件概率是已知某个事实时,另一件事发生的可能性。例如,我们已经考虑过“假设乘客为女性时,生还的概率”这类问题。
假设我们对x、y和z感兴趣,概率的基本符号表示如下:
-
:这是A发生的概率
-
:这是A和B同时发生的联合概率
-
:这是A或B发生的概率
-
:这是在B已发生的条件下,A发生的概率
-
:这是在C已发生的条件下,A和B同时发生的概率
前述变量之间的关系可以通过以下公式表示:
使用这个方法,我们可以通过来求解,假设
,从而得到所谓的贝叶斯定理:
我们可以这样理解:“在给定 B 的情况下,A 的概率等于在给定 A 的情况下 B 的概率,乘以 A 的概率,再除以 B 的概率”。
是先验概率:对 A 的初始信念程度。
是条件概率——在考虑了 B 的情况下,对 A 的信念程度。
商数 表示 B 对 A 提供的支持。
贝叶斯定理可能看起来令人生畏且抽象,因此我们来看看一个实际应用它的例子。假设我们正在检测一种已经感染了 1% 人口的疾病。我们有一种高灵敏度且高特异性的测试,虽然它不是完美的:
-
99% 的生病病人检测结果为阳性
-
99% 的健康病人测试结果为阴性
假设一位病人检测结果为阳性,那么这位病人实际上生病的概率是多少?
之前的要点似乎暗示着,阳性测试意味着有 99% 的机会患病,但这并没有考虑到这种疾病在整体人口中的稀有性。由于感染的概率(先验概率)非常小,即使你测试为阳性,你实际患病的概率也会大大降低。
让我们通过 10,000 名代表性的人群来计算这些数字。这样 100 人是生病的,9,900 人是健康的。如果我们对这 10,000 人进行测试,我们会发现 99 名生病的人检测为阳性(真正阳性),但是也有 99 名健康的人检测为阳性(假阳性)。如果你测试为阳性,那么实际上患病的概率是 ,即 50%:
我们可以使用贝叶斯定理来计算这个例子。让 y 表示“生病”,x 表示阳性结果事件“+”:
换句话说,尽管阳性测试极大地增加了你得病的可能性(从人口中 1% 的概率增加到了 99%),但你实际上生病的机会依然只有 50%——远低于测试准确度所暗示的 99%。
之前的例子给出了清晰的数字,我们现在来运行泰坦尼克号数据的例子。
在已知你是女性的情况下生存的概率等于在你生存的前提下是女性的概率,乘以生存的概率,再除以泰坦尼克号上女性的概率:
让我们回顾一下之前的列联表:
| :survived | :sex | :count |
|-----------+--------+--------|
| n | male | 682 |
| n | female | 127 |
| y | male | 161 |
| y | female | 339 |
P(survival|female) 是后验概率,即在已知证据的情况下对生存的信念程度。这正是我们要计算的值。
P(female|survival) 是在已知生存的情况下为女性的条件概率:
P(survival) 是先验概率,即对生存的初始信念程度:
P(female) 是证据:
将这些比例代入贝叶斯法则:
使用贝叶斯法则,我们计算出,在已知为女性的情况下,生存概率为,即 76%。
请注意,我们也可以通过查找总女性中的幸存者比例来计算这个值:。贝叶斯法则之所以受欢迎,是因为它提供了一种计算这种概率的方法,即使没有相应的列联表。
带有多个预测因子的贝叶斯定理
作为一个例子,说明如何在没有完整列联表的情况下使用贝叶斯法则,我们以第三等级男性为例。第三等级男性乘客的生存概率是多少?
让我们为这个新问题写出贝叶斯法则:
接下来,我们有两个列联表:
| :survived | :pclass | :count |
|-----------+---------+--------|
| n | first | 123 |
| n | second | 158 |
| n | third | 528 |
| y | first | 200 |
| y | second | 119 |
| y | third | 181 |
| :survived | :sex | :count |
|-----------+--------+--------|
| n | female | 127 |
| n | male | 682 |
| y | female | 339 |
| y | male | 161 |
"第三等级男性"不是我们任何列联表中的一个类别,无法简单查找。然而,通过使用贝叶斯定理,我们可以这样计算它:
我们正在寻找的后验概率是P(幸存|男性,第三等级)。
生存的先验概率与之前相同:,大约为 0.38。
条件概率是。这与
相同。换句话说,我们可以将这两个概率相乘:
证据是既是男性又是第三等级的概率::
将这一切综合起来:
实际上,在总共 493 名第三等级男性中,有 75 名幸存,真实的生存率为 15%。贝叶斯定理使我们能够在没有完整列联表的情况下,精确计算出真实答案。
朴素贝叶斯分类
我们通过贝叶斯定理得到的答案与实际结果略有不同,是因为在计算时,我们假设男性的概率和处于第三等级的概率是独立的。在下一部分,我们将使用贝叶斯定理生成一个朴素贝叶斯分类器。
注意
这个算法被称为朴素的原因是它假设所有变量都是独立的。我们知道,这通常并非如此,变量之间存在交互效应。例如,我们可能知道一些参数组合使得某个类别的可能性大大增加——例如,既是男性又在第三类。
让我们看看如何使用贝叶斯规则进行分类器的设计。对于第三类男性,生存与死亡这两个可能类别的贝叶斯定理如下所示:
最可能的类别是具有最大后验概率的类别。
在两个类别中作为共同因子出现。如果我们稍微放宽贝叶斯定理的要求,使其不一定返回概率,我们就可以去掉共同因子,得到以下结果:
我们仅仅从两个方程式的右侧去除了分母。由于我们不再计算概率,因此等号变成了 ,表示“与……成比例”。
将我们之前数据表中的值代入方程中得到:
我们立刻可以看到,我们并没有计算概率,因为这两个类别的和并不等于 1。对于我们的分类器来说,这无关紧要,因为我们本来就只会选择与最大值对应的类别。不幸的是,对于我们的第三类男性,朴素贝叶斯模型预测他将死亡。
让我们为一等舱女性做等效的计算:
幸运的是,对于我们的一等舱女性,模型预测她将会生还。
贝叶斯分类器是贝叶斯概率模型与决策规则(选择哪个类别)的结合体。前面描述的决策规则是最大后验规则,或称 MAP 规则。
实现朴素贝叶斯分类器
幸运的是,在代码中实现朴素贝叶斯模型要比理解其数学原理容易得多。第一步是简单地计算每个类别对应的每个特征值的示例数量。以下代码记录了每个类别标签下,每个参数出现的次数:
(defn inc-class-total [model class]
(update-in model [class :total] (fnil inc 0)))
(defn inc-predictors-count-fn [row class]
(fn [model attr]
(let [val (get row attr)]
(update-in model [class attr val] (fnil inc 0)))))
(defn assoc-row-fn [class-attr predictors]
(fn [model row]
(let [class (get row class-attr)]
(reduce (inc-predictors-count-fn row class)
(inc-class-total model class)
predictors))))
(defn bayes-classifier [data class-attr predictors]
(reduce (assoc-row-fn class-attr predictors) {} data))
标签是对应类别的属性(例如,在我们的泰坦尼克数据中,“survived”是与真和假类别对应的标签),而参数是与特征(性别和舱位)对应的属性序列。
它可以像这样使用:
(defn ex-4-26 []
(->> (load-data "titanic.tsv")
(:rows)
(bayes-classifier :survived [:sex :pclass])
(clojure.pprint/pprint)))
这个例子产生了以下的贝叶斯模型:
{:classes
{"n"
{:predictors
{:pclass {"third" 528, "second" 158, "first" 123},
:sex {"male" 682, "female" 127}},
:n 809},
"y"
{:predictors
{:pclass {"third" 181, "second" 119, "first" 200},
:sex {"male" 161, "female" 339}},
:n 500}},
:n 1309}
该模型只是一个两级层次结构,通过嵌套映射实现。在顶层是我们的两个类——"n"
和"y"
,分别对应“遇难”和“生还”。对于每个类,我们有一个预测变量的映射——:pclass
和:sex
。每个键对应一个可能值和计数的映射。除了预测变量的映射外,每个类还有一个计数:n
。
现在我们已经计算出了贝叶斯模型,我们可以实现我们的 MAP 决策规则。以下是一个计算提供类的条件概率的函数。例如,:
(defn posterior-probability [model test class-attr]
(let [observed (get-in model [:classes class-attr])
prior (/ (:n observed)
(:n model))]
(apply * prior
(for [[predictor value] test]
(/ (get-in observed [:predictors predictor value])
(:n observed))))))
给定一个特定的class-attr
,上面的代码将根据观测结果计算该类的后验概率。实现了早期的代码后,分类器只需要返回具有最大后验概率的类:
(defn bayes-classify [model test]
(let [probability (partial posterior-probability model test)
classes (keys (:classes model))]
(apply max-key probability classes)))
上面的代码计算了测试输入在每个模型类上的概率。返回的类是具有最高后验概率的那个类。
评估朴素贝叶斯分类器
既然我们已经编写了两个互补的函数,bayes-classifier
和bayes-classify
,我们可以使用我们的模型来进行预测。让我们在泰坦尼克号数据集上训练模型,并检查我们之前计算的第三等级男性和头等舱女性的预测结果:
(defn ex-4-27 []
(let [model (->> (load-data "titanic.tsv")
(:rows)
(naive-bayes :survived [:sex :pclass]))]
(println "Third class male:"
(bayes-classify model {:sex "male" :pclass "third"}))
(println "First class female:"
(bayes-classify model {:sex "female" :pclass "first"}))))
;; Third class male: n
;; First class female: y
这是一个很好的开始——我们的分类器与我们手动计算的结果一致。让我们来看看朴素贝叶斯分类器的正确率:
(defn ex-4-28 []
(let [data (:rows (load-data "titanic.tsv"))
model (bayes-classifier :survived [:sex :pclass] data)
test (fn [test]
(= (:survived test)
(bayes-classify model
(select-keys test [:sex :class]))))
results (frequencies (map test data))]
(/ (get results true)
(apply + (vals results)))))
;; 1021/1309
通过在整个数据集上重复我们的测试并比较输出,我们可以看到分类器正确回答的频率。78%的正确率与我们使用逻辑回归分类器得到的正确率相同。对于这么一个简单的模型,朴素贝叶斯表现得相当不错。
我们可以计算一个混淆矩阵:
(defn ex-4-195 []
(let [data (:rows (load-data "titanic.tsv"))
model (bayes-classifier :survived [:sex :pclass] data)
classify (fn [test]
(->> (select-keys test [:sex :pclass])
(bayes-classify model)))
ys (map :survived data)
y-hats (map classify data)]
(confusion-matrix ys y-hats)))
上面的代码生成了以下矩阵:
| | n | y |
|---+-----+-----|
| n | 682 | 127 |
| y | 161 | 339 |
这个混淆矩阵与我们之前从逻辑回归中获得的完全相同。尽管采用了非常不同的方法,但它们都能够以相同的准确度对数据集进行分类。
比较逻辑回归和朴素贝叶斯方法
尽管它们在我们的小型泰坦尼克号数据集上表现相同,但这两种分类方法通常适用于不同的任务。
尽管在概念上,朴素贝叶斯分类器比逻辑回归更简单,但在数据稀缺或参数数量非常大的情况下,朴素贝叶斯往往能超越逻辑回归。由于朴素贝叶斯能够处理非常大量的特征,它通常用于自动医疗诊断或垃圾邮件分类等问题。在垃圾邮件分类中,特征可能多达数万甚至数十万,每个单词都代表一个特征,有助于识别消息是否为垃圾邮件。
然而,朴素贝叶斯的一个缺点是它假设特征之间是独立的——在这种假设不成立的问题领域,其他分类器可能会超越朴素贝叶斯。对于大量数据,逻辑回归能够学习到更复杂的模型,并且可能比朴素贝叶斯更准确地进行分类。
还有一种方法——虽然简单且相对直观易建模——却能学习到参数之间更复杂的关系。这种方法就是决策树。
决策树
本章我们将探讨的第三种分类方法是决策树。决策树将分类过程建模为一系列测试,检查待分类物品某个或多个属性的值。它可以被视为类似于流程图,每个测试是流程中的一个分支。这个过程继续进行,不断测试和分支,直到到达叶节点。叶节点代表该物品最可能的分类。
决策树与逻辑回归和朴素贝叶斯有一些相似之处。尽管分类器可以支持类别变量而无需虚拟编码,但它同样能够通过反复分支来建模变量之间的复杂依赖关系。
在老式的猜谜游戏《二十个问题》中,一个人,称为“回答者”,选择一个物品,但不向其他人透露他们选择的是什么。其他所有玩家是“提问者”,轮流提出问题,目的是猜出回答者心中想到的物品。每个问题只能用简单的“是”或“否”回答。提问者的挑战是在仅有 20 个问题的情况下,猜出回答者心中所想的物品,并提出能够提供最多信息的问题。这不是一件容易的事——问得太笼统,你从答案中获得的信息就会很少;问得太具体,你将无法在 20 个问题内找到答案。
毋庸置疑,这些问题在决策树分类中也会出现。信息是可以量化的,而决策树的目标是提出那些可能带来最大信息增益的问题。
信息
假设我从一副 52 张扑克牌中随机抽取一张卡牌。你的挑战是猜测我抽到了哪张卡牌。但首先,我将回答一个可以用“是”或“否”回答的问题。你想问什么问题?
-
它是红色的吗?(红心或方块)
-
它是面牌吗?(杰克、皇后或国王)
我们将在接下来的页面中详细探讨这个问题。请花点时间思考你的问题。
一副扑克牌中有 26 张红色卡牌,因此随机抽到一张红色卡牌的概率是 。一副扑克牌中有 12 张面牌,因此随机抽到一张面牌的概率是
。
我与单个事件相关联的信息是:
Incanter 有一个log2
函数,使我们能够像这样计算信息:
(defn information [p]
(- (i/log2 p)))
在这里,log2
是以 2 为底的对数。因此:
由于图画牌的概率较低,它也承载了最高的信息值。如果我们知道卡片是图画牌,那么它可能是的卡片只有 12 张。如果我们知道卡片是红色的,那么仍然剩下 26 种可能性。
信息通常以比特为单位进行度量。知道一张卡片是红色的,其信息量仅为一个比特。计算机中的比特只能表示零或一。一个比特足以包含一个简单的 50/50 分割。知道卡片是图画牌则提供了两个比特的信息。这似乎表明最好的问题是“它是图画牌吗?”一个肯定的答案将包含大量的信息。
但是,如果我们发现答案是“不是图画牌”会发生什么呢?找出我选择的卡片不是图画牌的信息量是多少?
看起来我们现在可以问卡片是否是红色的,因为信息量更大。发现我们的卡片不是图画牌,仍然会剩下 36 种可能性。我们显然无法预知答案会是“是”还是“不是”,那么我们该如何选择最好的问题呢?
熵
熵是衡量不确定性的一个指标。通过计算熵,我们可以在所有可能的响应中取得信息内容的*衡。
注意
熵的概念是由鲁道夫·克劳修斯在十九世纪中期提出的,作为热力学新兴科学的一部分,用来解释燃烧引擎的一部分功能能量如何由于热量散失而丧失。在本章中,我们讨论的是香农熵,它来自克劳德·香农在二十世纪中期关于信息论的工作。这两个概念虽然来自科学的不同领域和背景,但它们是密切相关的。
熵,H,可以通过以下方式计算:
这里,P(x)是x发生的概率,I(P(x))是x的信息量。
例如,让我们比较一副牌的熵,其中每一类简单地分为“红色”和“非红色”。我们知道“红色”的信息量为 1,概率为。对于“非红色”也是如此,因此熵是以下和:
以这种方式分割牌组的熵为 1。那么,如果将牌组分为“图画牌”和“非图画牌”呢?“图画牌”的信息量是 2.12,概率是。“非图画牌”的信息量是 0.38,概率是
:
如果我们把扑克牌的牌面看作一系列的类别(正类和负类),我们可以使用 Clojure 计算两个牌堆的熵值:
(defn entropy [xs]
(let [n (count xs)
f (fn [x]
(let [p (/ x n)]
(* p (information p))))]
(->> (frequencies xs)
(vals)
(map f)
(reduce +))))
(defn ex-4-30 []
(let [red-black (concat (repeat 26 1)
(repeat 26 0))]
(entropy red-black)))
;; 1.0
(defn ex-4-202 []
(let [picture-not-picture (concat (repeat 12 1)
(repeat 40 0))]
(entropy picture-not-picture)))
;; 0.779
熵是衡量不确定性的指标。通过将牌堆分成“人物卡”和“非人物卡”两组,熵值下降,说明询问卡片是否是人物卡是最好的问题。即使我们发现我的卡片不是人物卡,仍然是最好的问题,因为牌堆中的不确定性减少了。熵不仅仅适用于数字序列,也适用于任何序列。
(entropy "mississippi")
;; 1.82
小于
(entropy "yellowstone")
;; 2.91
尽管它们长度相等,但因为字母间有更多的一致性,熵较低。
信息增益
熵告诉我们最好的问题——也就是能够最大程度减少牌堆熵值的问题——是询问卡片是否为人物卡。
一般来说,我们可以利用熵来判断某个分组是否合适,方法是使用信息增益的理论。为了说明这一点,回到泰坦尼克号生还者的例子。假设我随机选择了一名乘客,你需要猜测他是否生还。这次,在你回答之前,我会告诉你以下两件事之一:
-
他们的性别(男性或女性)
-
他们所坐的舱位(头等舱、二等舱或三等舱)
你更愿意知道什么?
一开始可能看起来最好的问题是问乘客是坐在哪一等舱。这样可以将乘客分成三组,正如我们在扑克牌中看到的那样,更小的组别效果更好。但不要忘记,目标是猜测乘客的生存情况。为了确定最好的问题,我们需要知道哪个问题能给我们带来最大的信息增益。
信息增益的计算方式是,学习到新信息前后的熵值之差。让我们计算一下当我们得知乘客是男性时的信息增益。首先,计算所有乘客的生存率基线熵。
我们可以使用现有的熵计算方法,并传递生存类别的序列:
(defn ex-4-32 []
(->> (load-data "titanic.tsv")
(:rows)
(map :survived)
(entropy)))
;; 0.959
这是一个高熵值。我们已经知道,熵值为 1.0 表示 50/50 的分配,但我们也知道,泰坦尼克号的生还率大约是 38%。这种明显的差异是因为熵并不是线性变化的,而是如以下图示那样快速接* 1:
接下来,考虑按性别划分的生存熵。现在我们有两个组来计算熵:男性和女性。总熵是这两个组的加权*均值。我们可以通过使用以下函数在 Clojure 中计算任意数量组的加权*均值:
(defn weighted-entropy [groups]
(let [n (count (apply concat groups))
e (fn [group]
(* (entropy group)
(/ (count group) n)))]
(->> (map e groups)
(reduce +))))
(defn ex-4-33 []
(->> (load-data "titanic.tsv")
(:rows)
(group-by :sex)
(vals)
(map (partial map :survived))
(weighted-entropy)))
;; 0.754
我们可以看到,按性别分组的生存类别的加权熵低于我们从所有乘客中获得的 0.96。因此,我们的信息增益为0.96 - 0.75 = 0.21比特。
我们可以轻松地将增益表示为一个基于我们刚刚定义的entropy
和weighted-entropy
函数的 Clojure 函数:
(defn information-gain [groups]
(- (entropy (apply concat groups))
(weighted-entropy groups)))
让我们用这个方法来计算如果我们按乘客类别分组时的增益:
(defn ex-4-205 []
(->> (load-data "titanic.tsv")
(:rows)
(group-by :pclass)
(vals)
(map (partial map :survived))
(information-gain)))
;; 0.07
乘客类别的信息增益是 0.07,性别的信息增益是 0.21。因此,在分类生存率时,知道乘客的性别比他们的旅行舱位更有用。
使用信息增益来识别最佳预测器
使用我们刚刚定义的函数,我们可以构建一个有效的树分类器。我们希望有一种通用的方法来计算给定输出类别的特定预测属性的信息增益。在前面的例子中,预测器是:pclass
,类别属性是:survived
,但是我们可以编写一个通用函数,接受这些关键词作为参数class-attr
和predictor
:
(defn gain-for-predictor [class-attr xs predictor]
(let [grouped-classes (->> (group-by predictor xs)
(vals)
(map (partial map class-attr)))]
(information-gain grouped-classes)))
接下来,我们需要一种方法来计算给定一组行的最佳预测器。我们可以简单地将前面的函数映射到所有期望的预测器,并返回增益最高的预测器:
(defn best-predictor [class-attr xs predictors]
(let [gain (partial gain-for-predictor class-attr xs)]
(when (seq predictors)
(apply max-key gain predictors))))
让我们通过询问:sex
和:pclass
哪个预测器是最好的预测器来测试这个函数:
(defn ex-4-35 []
(->> (load-data "titanic.tsv")
(:rows)
(best-predictor :survived [:sex :pclass])))
;; :sex
令人放心的是,我们得到了与之前相同的答案。决策树允许我们递归地应用这种逻辑,构建一个树结构,在每个分支上基于该分支中的数据选择最好的问题。
递归地构建决策树
通过递归地将我们编写的函数应用于数据,我们可以构建一个数据结构,表示树中每一层的最佳类别划分。首先,让我们定义一个函数,给定一个数据序列时返回众数(最常见的)类别。当我们的决策树到达无法再划分数据的点时(因为熵为零或没有剩余的预测器可以划分数据),我们将返回众数类别。
(defn modal-class [classes]
(->> (frequencies classes)
(apply max-key val)
(key)))
有了这个简单的函数,我们准备构建决策树。这个过程实现为一个递归函数。给定一个类别属性、一组预测器和一组值,我们通过将class-attr
映射到我们的xs
上来构建可用类别的序列。如果熵为零,则所有类别相同,因此我们只返回第一个类别。
如果我们组中的类别不相同,则需要选择一个预测器来进行分支。我们使用best-predictor
函数选择与最高信息增益相关联的预测器。我们将其从预测器列表中移除(没有必要重复使用相同的预测器),并构造一个tree-branch
函数。这是对剩余预测器的decision-tree
部分递归调用。
最终,我们将数据按best-predictor
分组,并对每个组调用部分应用的tree-branch
函数。这会导致整个过程再次重复,但这次只在group-by
定义的数据子集上进行。返回值被封装在一个向量中,连同预测器一起:
(defn decision-tree [class-attr predictors xs]
(let [classes (map class-attr xs)]
(if (zero? (entropy classes))
(first classes)
(if-let [predictor (best-predictor class-attr
predictors xs)]
(let [predictors (remove #{predictor} predictors)
tree-branch (partial decision-tree
class-attr predictors)]
(->> (group-by predictor xs)
(map-vals tree-branch)
(vector predictor)))
(modal-class classes)))))
让我们可视化该函数对:sex
和:pclass
预测器的输出。
(defn ex-4-36 []
(->> (load-data "titanic.tsv")
(:rows)
(decision-tree :survived [:pclass :sex])
(clojure.pprint/pprint)))
;; [:sex
;; {"female" [:pclass {"first" "y", "second" "y", "third" "n"}],
;; "male" [:pclass {"first" "n", "second" "n", "third" "n"}]}]
我们可以看到,决策树是如何以向量的形式表示的。向量的第一个元素是用于分支树的预测器。第二个元素是一个包含该预测器属性的映射,其键为"male"
和"female"
,对应的值进一步分支到:pclass
。
为了展示如何使用此函数构建任意深度的树,让我们添加一个额外的预测器:age
。不幸的是,我们构建的树分类器只能处理分类数据,因此我们将年龄这一连续变量分为三个简单类别:unknown
、child
和adult
。
(defn age-categories [age]
(cond
(nil? age) "unknown"
(< age 13) "child"
:default "adult"))
(defn ex-4-37 []
(let [data (load-data "titanic.tsv")]
(->> (i/transform-col data :age age-categories)
(:rows)
(decision-tree :survived [:pclass :sex :age])
(clojure.pprint/pprint))))
这段代码生成了如下的树:
[:sex
{"female"
[:pclass
{"first" [:age {"adult" "y", "child" "n", "unknown" "y"}],
"second" [:age {"adult" "y", "child" "y", "unknown" "y"}],
"third" [:age {"adult" "n", "child" "n", "unknown" "y"}]}],
"male"
[:age
{"unknown" [:pclass {"first" "n", "second" "n", "third" "n"}],
"adult" [:pclass {"first" "n", "second" "n", "third" "n"}],
"child" [:pclass {"first" "y", "second" "y", "third" "n"}]}]}]
请注意,最优的总体预测器仍然是乘客的性别,和之前一样。然而,如果性别是男性,那么年龄是下一个最具信息量的预测器。另一方面,如果性别是女性,那么乘客等级是最具信息量的预测器。由于树的递归特性,每个分支只能为该树的特定分支中的数据确定最佳预测器。
使用决策树进行分类
使用从决策树函数返回的数据结构,我们拥有分类乘客到最可能类别所需的所有信息。我们的分类器也将是递归实现的。如果传入的是向量模型,我们知道它将包含两个元素——预测器和分支。我们从模型中解构出预测器和分支,然后确定我们的测试位于哪个分支上。为此,我们只需使用(get test predictor)
从测试中获取预测器的值。我们需要的分支将是与该值对应的分支。
一旦我们有了分支,我们需要再次在该分支上调用tree-classify
。因为我们处于尾部位置(在if
之后没有进一步的逻辑应用),所以可以调用recur
,允许 Clojure 编译器优化我们的递归函数调用:
(defn tree-classify [model test]
(if (vector? model)
(let [[predictor branches] model
branch (get branches (get test predictor))]
(recur branch test))
model))
我们继续递归调用tree-classify
,直到(vector? model)
返回 false 为止。此时,我们将已经遍历了决策树的全部深度并达到了叶节点。此时,model
参数包含了预测的类别,因此我们直接返回它。
(defn ex-4-38 []
(let [data (load-data "titanic.tsv")
tree (->> (i/transform-col data :age age-categories)
(:rows)
(decision-tree :survived [:pclass :sex :age]))
test {:sex "male" :pclass "second" :age "child"}]
(tree-classify tree test)))
;; "y"
决策树预测来自二等舱的年轻男性将会生还。
评估决策树分类器
和之前一样,我们可以计算我们的混淆矩阵和卡帕统计量:
(defn ex-4-39 []
(let [data (-> (load-data "titanic.tsv")
(i/transform-col :age age-categories)
(:rows))
tree (decision-tree :survived [:pclass :sex :age] data)]
(confusion-matrix (map :survived data)
(map (partial tree-classify tree) data))))
混淆矩阵如下所示:
| | n | y |
|---+-----+-----|
| n | 763 | 46 |
| y | 219 | 281 |
我们可以立即看到,分类器产生了大量的假阴性:219
。让我们计算卡帕统计量:
(defn ex-4-40 []
(let [data (-> (load-data "titanic.tsv")
(i/transform-col :age age-categories)
(:rows))
tree (decision-tree :survived [:pclass :sex :age] data)
ys (map :survived data)
y-hats (map (partial tree-classify tree) data)]
(float (kappa-statistic ys y-hats))))
;; 0.541
我们的树形分类器的表现远不如我们尝试过的其他分类器。我们可以尝试提高准确度的一种方法是增加我们使用的预测变量的数量。与其使用粗略的年龄分类,不如直接使用年龄的实际数据作为特征。这将使我们的分类器能够更好地区分乘客。顺便提一下,我们还可以加入票价信息:
(defn ex-4-41 []
(let [data (-> (load-data "titanic.tsv")
(:rows))
tree (decision-tree :survived
[:pclass :sex :age :fare] data)
ys (map :survived data)
y-hats (map (partial tree-classify tree) data)]
(float (kappa-statistic ys y-hats))))
;; 0.925
太棒了!我们取得了令人惊叹的进展;我们的新模型是迄今为止最好的。通过增加更精细的预测变量,我们构建了一个能够以非常高的准确度进行预测的模型。
然而,在我们过于庆祝之前,我们应该仔细考虑我们的模型的通用性。构建分类器的目的是通常是对新数据进行预测。这意味着它应该能够在之前未见过的数据上表现良好。我们刚刚构建的模型存在一个显著问题。为了理解它,我们将转向 clj-ml 库,库中包含了多种用于训练和测试分类器的函数。
使用 clj-ml 进行分类
虽然构建我们自己的逻辑回归、朴素贝叶斯和决策树模型为我们提供了一个讨论其背后理论的宝贵机会,但 Clojure 为我们提供了几个构建分类器的库。其中支持较好的一个是 clj-ml 库。
clj-ml 库目前由 Josua Eckroth 维护,并在他的 GitHub 页面上有文档:github.com/joshuaeckroth/clj-ml
。该库为运行上一章中描述的线性回归以及使用逻辑回归、朴素贝叶斯、决策树和其他算法的分类提供了 Clojure 接口。
注意
clj-ml 中大多数机器学习功能的底层实现是由 Java 机器学习库Weka
提供的。Waikato 知识分析环境(Weka)是一个开源的机器学习项目,主要由新西兰怀卡托大学的机器学习小组发布和维护(www.cs.waikato.ac.nz/ml/
)。
使用 clj-ml 加载数据
由于它对机器学习算法的专业支持,clj-ml 提供了用于创建数据集的函数,这些函数可以识别数据集的类和属性。clj-ml.data/make-dataset
函数允许我们创建一个数据集,并将其传递给 Weka 的分类器。在以下代码中,我们将 clj-ml.data
引入为 mld
:
(defn to-weka [dataset]
(let [attributes [{:survived ["y" "n"]}
{:pclass ["first" "second" "third"]}
{:sex ["male" "female"]}
:age
:fare]
vectors (->> dataset
(i/$ [:survived :pclass :sex :age :fare])
(i/to-vect))]
(mld/make-dataset :titanic-weka attributes vectors
{:class :survived})))
mld/make-dataset
期望接收数据集的名称、一个属性向量、作为行向量序列的数据集,以及一个可选的设置映射。属性用于标识列名,并且在分类变量的情况下,还会列举所有可能的类别。例如,像 :survived
这样的分类变量将以一个映射 {:survived ["y" "n"]}
的形式传入,而像 :age
和 :fare
这样的连续变量将以直接的关键词传入。数据集必须作为行向量序列提供。为了构建这个,我们只是简单地使用 Incanter 的 i/$
函数,并对结果调用 i/to-vect
。
注意
虽然 make-dataset
是一种灵活的方式,用于从任意数据源创建数据集,但 clj-ml.io
提供了一个 load-instances
函数,可以从各种来源加载数据,例如 CSV 文件、属性-关系文件格式(ARFF)文件和 MongoDB 数据库。
在将数据集转换为 clj-ml 能理解的格式后,是时候训练一个分类器了。
在 clj-ml 中构建决策树
Clj-ml 实现了多种分类器,所有这些分类器都可以通过 cl/make-classifier
函数访问。我们向构造函数传递两个关键词参数:分类器类型和要使用的算法。例如,看看 :decision-tree
和 :c45
算法。C4.5 算法是由 Ross Quinlan 提出的,它基于信息熵构建树形分类器,方式与我们在本章早些时候实现的 tree-classifier
函数相同。C4.5 在我们构建的分类器基础上做了几项扩展:
-
当没有任何预测变量提供信息增益时,C4.5 会在树的上方创建一个决策节点,并使用该类别的期望值。
-
如果遇到一个先前未见过的类别,C4.5 将在树的上方创建一个决策节点,并使用该类别的期望值。
我们可以用以下代码在 clj-ml 中创建一个决策树:
(defn ex-4-42 []
(let [dataset (to-weka (load-data "titanic.tsv"))
classifier (-> (cl/make-classifier :decision-tree :c45)
(cl/classifier-train dataset))
classify (partial cl/classifier-classify classifier)
ys (map str (mld/dataset-class-values dataset))
y-hats (map name (map classify dataset))]
(println "Confusion:" (confusion-matrix ys y-hats))
(println "Kappa:" (kappa-statistic ys y-hats))))
上述代码返回以下信息:
;; Confusion:
;; | | n | y |
;; |---+-----+-----|
;; | n | 712 | 97 |
;; | y | 153 | 347 |
;;
;; Kappa: 0.587
请注意,在训练我们的分类器或使用它进行预测时,我们无需显式提供类别和预测属性。Weka 数据集已经包含了每个实例的类别属性信息,分类器将使用它能够获取的所有属性来做出预测。尽管如此,结果仍然不如我们之前得到的那么好。原因在于,Weka 的决策树实现拒绝对数据过度拟合。
偏差与方差
过拟合是机器学习算法中常见的问题,虽然算法能够在训练数据集上产生非常准确的结果,但却无法很好地从所学知识中推广到新的数据。我们说,过拟合数据的模型具有非常高的方差。当我们在包含乘客年龄这一数值数据的情况下训练我们的决策树时,我们就出现了过拟合。
相反,某些模型可能会有非常高的偏差。这是一种模型强烈倾向于某一特定结果的情况,无论训练示例如何与之相反。回想一下我们的例子——一个总是预测幸存者会死亡的分类器。这个分类器在幸存者比例较低的数据集上表现良好,但在其他数据集上表现则很差。
在高偏差的情况下,模型在训练阶段很可能无法在多样化的输入上表现良好;在高方差的情况下,模型在与训练数据不同的数据上也很可能表现不佳。
注意
就像在假设检验中需要*衡第一类错误和第二类错误一样,在机器学习中*衡偏差和方差对于获得良好的结果至关重要。
如果我们有太多的特征,学习到的假设可能会非常好地拟合训练集,但却无法很好地推广到新的样本。
过拟合
因此,识别过拟合的关键在于对分类器进行未见过的样本测试。如果分类器在这些示例上表现不佳,则可能存在过拟合的情况。
通常的做法是将数据集分为两组:训练集和测试集。训练集用于训练分类器,而测试集用于判断分类器是否能够很好地从已学的知识中进行推广。
测试集应该足够大,以确保它能够代表数据集中的样本,但仍然应保留大部分记录用于训练。测试集通常占整个数据集的 10%到 30%。让我们使用clj-ml.data/do-split-dataset
返回两个实例集。较小的将是我们的测试集,较大的将是我们的训练集:
(defn ex-4-43 []
(let [[test-set train-set] (-> (load-data "titanic.tsv")
(to-weka)
(mld/do-split-dataset :percentage
30))
classifier (-> (cl/make-classifier :decision-tree :c45)
(cl/classifier-train train-set))
classify (partial cl/classifier-classify classifier)
ys (map str (mld/dataset-class-values test-set))
y-hats (map name (map classify test-set))]
(println "Confusion:" (confusion-matrix ys y-hats))
(println "Kappa:" (kappa-statistic ys y-hats))))
;; Confusion:
;; | | n | y |
;; |---+-----+-----|
;; | n | 152 | 9 |
;; | y | 65 | 167 |
;;
;; Kappa: 0.630
如果你将这个 Kappa 统计量与之前的进行比较,你会看到其实我们的准确率在未见过的数据上有所提高。虽然这看起来表明我们的分类器没有过拟合训练集,但它似乎并不现实,因为我们的分类器竟然能对新数据做出比我们提供给它的训练数据更准确的预测。
这表明我们可能在测试集中运气很好。也许测试集恰好包含了一些相对容易分类的乘客,而这些乘客在训练集中并不常见。让我们看看如果我们从最后的 30%数据中取测试集会发生什么:
(defn ex-4-44 []
(let [[train-set test-set] (-> (load-data "titanic.tsv")
(to-weka)
(mld/do-split-dataset :percentage
70))
classifier (-> (cl/make-classifier :decision-tree :c45)
(cl/classifier-train train-set))
classify (partial cl/classifier-classify classifier)
ys (map str (mld/dataset-class-values test-set))
y-hats (map name (map classify test-set))]
(println "Kappa:" (kappa-statistic ys y-hats))))
;; Kappa: 0.092
分类器在数据集最后 30%的测试数据上表现不佳。因此,为了公*地反映分类器的整体实际表现,我们需要确保在数据的多个随机子集上进行测试,以*衡分类器的表现。
交叉验证
将数据集划分为互补的训练数据和测试数据的过程称为交叉验证。为了减少我们刚才看到的输出中的波动——即在测试集上比在训练集上的误差率更低——通常会在数据的不同划分上运行多轮交叉验证。通过对所有运行结果进行*均,我们能够更准确地了解模型的真实准确性。这是一个如此常见的做法,以至于 clj-ml 包含了一个专门用于此目的的函数:
(defn ex-4-45 []
(let [dataset (-> (load-data "titanic.tsv")
(to-weka))
classifier (-> (cl/make-classifier :decision-tree :c45)
(cl/classifier-train dataset))
evaluation (cl/classifier-evaluate classifier
:cross-validation
dataset 10)]
(println (:confusion-matrix evaluation))
(println (:summary evaluation))))
在前面的代码中,我们使用cl/classifier-evaluate
对我们的数据集进行 10 次交叉验证。结果会以一个映射的形式返回,包含有关模型性能的有用信息——例如,混淆矩阵和一系列总结统计数据——包括我们一直在追踪的 kappa 统计值。我们打印出 clj-ml 提供的混淆矩阵和总结字符串,如下所示:
;; === Confusion Matrix ===
;;
;; a b <-- classified as
;; 338 162 | a = y
;; 99 710 | b = n
;;
;;
;; Correctly Classified Instances 1048 80.0611 %
;; Incorrectly Classified Instances 261 19.9389 %
;; Kappa statistic 0.5673
;; Mean absolute error 0.284
;; Root mean squared error 0.3798
;; Relative absolute error 60.1444 %
;; Root relative squared error 78.171 %
;; Coverage of cases (0.95 level) 99.3888 %
;; Mean rel. region size (0.95 level) 94.2704 %
;; Total Number of Instances 1309
经过 10 次交叉验证后的 kappa 值为 0.56,仅比我们在训练数据上验证的模型稍低。这似乎是我们能达到的最高水*。
解决高偏差
过拟合可能是由于在模型中包含了过多特征造成的——例如,当我们在决策树中将年龄作为分类变量时——而高偏差则可能由其他因素引起,包括数据量不足。
提高模型准确性的一个简单方法是确保训练集中的缺失值被处理掉。模型会丢弃缺失的值,这限制了模型可以学习的训练样本数量。像这样的相对较小的数据集中的每一个样本都可能对结果产生实质性的影响,并且数据集中有多个年龄值和一个票价值缺失。
我们可以简单地用数值列中的均值替代缺失值。这是一个合理的默认值,且是一种公*的折衷——通过略微降低字段的方差,我们有可能获得更多的训练样本。
clj-ml 在clj-ml.filters
命名空间中包含了众多能够以某种方式修改数据集的过滤器。一个有用的过滤器是:replace-missing-values
,它会将任何缺失的数值替换为数据集中的均值。对于分类数据,则会替换为众数类别。
(defn ex-4-46 []
(let [dataset (->> (load-data "titanic.tsv")
(to-weka)
(mlf/make-apply-filter
:replace-missing-values {}))
classifier (-> (cl/make-classifier :decision-tree :c45)
(cl/classifier-train dataset))
evaluation (cl/classifier-evaluate classifier
:cross-validation
dataset 10)]
(println (:kappa evaluation))))
;; 0.576
仅仅通过填补年龄列中的缺失值,就能使我们的 kappa 统计量有所提高。我们的模型目前在区分具有不同生存结果的乘客时遇到困难,更多的信息可能有助于算法确定正确的类别。虽然我们可以回到数据中并补充所有剩余字段,但也可以通过现有特征构造新特征。
注意
对于数值型特征,增加参数的另一种方式是将这些数值的多项式版本作为特征。例如,我们可以通过对现有的年龄值进行*方或立方,来创建年龄²和年龄³的特征。尽管这些可能看起来不会为模型提供新信息,但多项式的缩放方式不同,能够为模型提供不同的特征供其学习。
我们用来*衡偏差和方差的最终方法是将多个模型的输出结合起来。
集成学习与随机森林
集成学习将多个模型的输出结合起来,从而获得比任何单一模型更好的预测结果。其原理是,许多弱学习者的组合准确率大于任何单个弱学习者的准确率。
随机森林是由 Leo Breiman 和 Adele Cutler 设计并注册商标的集成学习算法。它将多个决策树组合成一个大型森林学习器。每棵树使用可用特征的子集来训练数据,这意味着每棵树对数据的理解略有不同,且能生成与同伴不同的预测。
在 clj-ml 中创建一个随机森林只需要改变cl/make-classifier
的参数,将其设置为:decision-tree
和:random-forest
。
装袋与提升
装袋和提升是两种相对的集成模型创建技术。提升是通过训练每个新模型来强调正确分类那些之前模型未能正确分类的训练样本,从而构建集成的通用技术。它是一种元算法。
注意
最流行的提升算法之一是AdaBoost,它是“自适应提升”的缩写。只要每个模型的表现略好于随机猜测,组合后的输出就能证明收敛到一个强学习者。
Bagging 是“自助聚合”(bootstrap aggregating)的缩写,是一种常用于决策树学习器的元算法,但也可以应用于其他学习器。在单棵树可能会过拟合训练数据的情况下,bagging 有助于减少组合模型的方差。它通过带替代地抽样训练数据来实现这一点,就像我们在本章开头使用的自助标准误一样。因此,集成中的每个模型对世界的理解都是不完全的,这使得组合模型更不容易在训练数据上学习到过于具体的假设。随机森林就是一种 bagging 算法的例子。
(defn ex-4-47 []
(let [dataset (->> (load-data "titanic.tsv")
(to-weka)
(mlf/make-apply-filter
:replace-missing-values {}))
classifier (cl/make-classifier :decision-tree
:random-forest)
evaluation (cl/classifier-evaluate classifier
:cross-validation
dataset 10)]
(println (:confusion-matrix evaluation))
(println (:summary evaluation))))
使用随机森林分类器时,你应该观察到一个大约为 0.55 的卡帕值,略低于我们一直在优化的决策树。随机森林的实现牺牲了一些模型的方差。
尽管这可能看起来令人失望,但实际上这正是随机森林受欢迎的原因之一。它们在偏差和方差之间取得*衡的能力,使它们成为灵活且通用的分类器,适用于各种问题。
保存分类器到文件
最后,我们可以使用clj-ml.utils/serialize-to-file
将分类器写入文件:
(defn ex-4-48 []
(let [dataset (->> (load-data "titanic.tsv")
(to-weka)
(mlf/make-apply-filter
:replace-missing-values {}))
classifier (cl/make-classifier :decision-tree
:random-forest)
file (io/file (io/resource "classifier.bin"))]
(clu/serialize-to-file classifier file)))
在稍后的某个时刻,我们可以使用clj-ml.utils/deserialize-from-file
加载已训练的分类器,并立即开始对新数据进行分类。
总结
在本章中,我们学习了如何利用分类变量将数据分组为类别。
我们已经学习了如何使用赔率比和相对风险量化组间差异,并且如何使用X²检验对组进行统计显著性检验。我们了解了如何构建适合分类任务的机器学习模型,使用了多种技术:逻辑回归、朴素贝叶斯、决策树和随机森林,并学习了几种评估方法;混淆矩阵和卡帕统计量。我们还了解了机器学习中高偏差和过拟合的相对风险,以及如何通过交叉验证来确保你的模型没有过拟合。最后,我们看到了 clj-ml 库如何帮助准备数据、构建多种类型的分类器,并将它们保存以备将来使用。
在下一章,我们将学习如何将目前为止学到的一些技术应用于处理超出任何单一计算机存储和处理能力的大型数据集——所谓的大数据。我们将看到,本章中我们遇到的技术之一——梯度下降——特别适用于大规模参数优化。
第五章 大数据
"更多即不同。" | ||
---|---|---|
--Philip Warren Anderson |
在前几章中,我们使用回归技术将模型拟合到数据中。例如,在第三章,相关性中,我们构建了一个线性模型,利用普通最小二乘法和正态方程,通过运动员的身高和对数体重拟合了一条直线。在第四章,分类中,我们使用 Incanter 的优化命名空间,最小化逻辑代价函数,并构建了一个泰坦尼克号乘客的分类器。在本章中,我们将以适合更大数据量的方式应用类似的分析。
我们将使用一个相对简单的数据集,只有 100,000 条记录。这不是大数据(数据大小为 100 MB,足以在一台机器的内存中舒适地存储),但它足够大,可以展示大规模数据处理的常用技术。本章将以 Hadoop(分布式计算的流行框架)为案例,重点讨论如何通过并行化将算法扩展到非常大的数据量。我们将介绍 Clojure 提供的两个与 Hadoop 一起使用的库——Tesser和Parkour。
然而,在深入讨论 Hadoop 和分布式数据处理之前,我们将首先看看一些相同的原则,这些原则使得 Hadoop 在大规模上有效,并且这些原则也可以应用于单机的数据处理,借助现代计算机的并行能力。
下载代码和数据
本章使用的数据来自美国国税局(IRS)按邮政编码划分的个人收入数据。数据包含按州、邮政编码和收入等级分类的收入和税务项目。
文件大小为 100 MB,可以从www.irs.gov/pub/irs-soi/12zpallagi.csv
下载到示例代码的数据目录中。由于该文件包含美国国税局的所得税统计(SoI),我们已将文件重命名为soi.csv
以供示例使用。
注意
本章的示例代码可以从 Packt Publishing 的网站或github.com/clojuredatascience/ch5-big-data
获取。
和往常一样,提供了一个脚本,可以为你下载并重命名数据。可以在项目目录内的命令行中运行:
script/download-data.sh
如果你运行此操作,文件将自动下载并重命名。
检查数据
下载数据后,查看文件第一行的列标题。访问文件第一行的一种方法是将文件加载到内存中,按换行符分割,并取第一个结果。Clojure 核心库中的函数slurp
将返回整个文件作为字符串:
(defn ex-5-1 []
(-> (slurp "data/soi.csv")
(str/split #"\n")
(first)))
该文件大约 100 MB 大小。当加载到内存并转换为对象表示时,数据在内存中将占用更多空间。当我们只对第一行感兴趣时,这样的做法尤其浪费。
幸运的是,如果我们利用 Clojure 的懒序列,我们不必将整个文件加载到内存中。我们可以返回文件的引用,然后逐行读取:
(defn ex-5-2 []
(-> (io/reader "data/soi.csv")
(line-seq)
(first)))
在前面的代码中,我们使用 clojure.java.io/reader
返回文件的引用。同时,我们使用 clojure.core
函数 line-seq
返回文件的懒序列。通过这种方式,我们可以读取比可用内存更大的文件。
上一个函数的结果如下:
"STATEFIPS,STATE,zipcode,AGI_STUB,N1,MARS1,MARS2,MARS4,PREP,N2,NUMDEP,A00100,N00200,A00200,N00300,A00300,N00600,A00600,N00650,A00650,N00900,A00900,SCHF,N01000,A01000,N01400,A01400,N01700,A01700,N02300,A02300,N02500,A02500,N03300,A03300,N00101,A00101,N04470,A04470,N18425,A18425,N18450,A18450,N18500,A18500,N18300,A18300,N19300,A19300,N19700,A19700,N04800,A04800,N07100,A07100,N07220,A07220,N07180,A07180,N07260,A07260,N59660,A59660,N59720,A59720,N11070,A11070,N09600,A09600,N06500,A06500,N10300,A10300,N11901,A11901,N11902,A11902"
文件中有 77 个字段,因此我们不会全部列出。前四个字段是:
-
STATEFIPS
:这是联邦信息处理系统(FIPS)代码。 -
STATE
:这是州的两字母代码。 -
zipcode
:这是 5 位数的邮政编码。 -
AGI_STUB
:这是调整后总收入的一部分,按以下方式分箱:-
$1 至 $25,000
-
$25,000 至 $50,000
-
$50,000 至 $75,000
-
$75,000 至 $100,000
-
$100,000 至 $200,000
-
$200,000 或更多
-
我们感兴趣的其他字段如下:
-
N1
:提交的报税表数量 -
MARS2
:提交的联合报税表数量 -
NUMDEP
:受抚养人数量 -
N00200
:包含薪水和工资的报税表数量 -
N02300
:包含失业补偿的报税表数量
如果你感兴趣,可以在 www.irs.gov/pub/irs-soi/12zpdoc.doc
查阅完整的列定义列表。
计数记录
我们的文件当然很宽,但它高吗?我们想要确定文件中的总行数。创建了懒序列后,这只是计算序列长度的问题:
(defn ex-5-3 []
(-> (io/reader "data/soi.csv")
(line-seq)
(count)))
上述示例返回 166,905 行,包括标题行,因此我们知道文件中实际上有 166,904 行。
count
函数是计算序列中元素数量的最简单方法。对于向量(以及实现计数接口的其他类型),这是最有效的方法,因为集合已经知道包含多少个元素,因此无需重新计算。然而,对于懒序列,唯一确定序列中包含多少元素的方法是从头到尾逐步遍历它。
Clojure 中的 count
实现是用 Java 编写的,但等效的 Clojure 版本是对序列进行 reduce 操作,如下所示:
(defn ex-5-4 []
(->> (io/reader "data/soi.csv")
(line-seq)
(reduce (fn [i x]
(inc i)) 0)))
我们传递给reduce
的前一个函数接受一个计数器i
和来自序列的下一个元素x
。对于每个x
,我们简单地增加计数器i
。reduce
函数接受一个初始值零,代表“无”的概念。如果没有要合并的行,则会返回零。
从版本 1.5 开始,Clojure 提供了reducers
库(clojure.org/reducers
),它提供了一种通过牺牲内存效率来换取速度的替代方式来执行合并操作。
reducers 库
我们之前实现的count
操作是一个顺序算法。每一行按顺序处理,直到序列耗尽。但是,这个操作并没有要求它必须按这种方式进行。
我们可以将行数分成两个序列(理想情况下长度大致相等),然后独立地对每个序列进行合并操作。当我们完成时,只需将每个序列的总行数相加,即可得到文件中的总行数:
如果每个Reduce在自己的处理单元上运行,那么这两个计数操作将会并行执行。其他条件相同的情况下,算法将运行得更快,是原来的两倍。这就是clojure.core.reducers
库的目标之一——通过利用多核处理器,让算法能够在单台机器上实现并行化。
使用 Reducers 的并行折叠
由 reducers 库实现的reduce
的并行版本称为fold。为了使用fold
,我们必须提供一个合并函数,它将接收我们减少过的序列结果(部分行计数)并返回最终结果。由于我们的行计数是数字,所以合并函数就是+
。
注意
Reducers 是 Clojure 标准库的一部分,无需作为外部依赖添加。
调整后的示例,使用clojure.core.reducers
作为r
,如下所示:
(defn ex-5-5 []
(->> (io/reader "data/soi.csv")
(line-seq)
(r/fold + (fn [i x]
(inc i)))))
合并函数+
已作为第一个参数传递给fold
,我们未更改的reduce
函数则作为第二个参数传入。我们不再需要传递初始值零——fold
会通过调用没有参数的合并函数来获取初始值。我们之前的示例之所以有效,是因为+
在没有参数的情况下已经返回零:
(defn ex-5-6 []
(+))
;; 0
因此,要参与折叠,合并函数必须有两个实现:一个没有参数,返回标识值,另一个有两个参数,用于合并这两个参数。当然,不同的折叠操作将需要不同的合并函数和标识值。例如,乘法的标识值是1
。
我们可以将用标识值初始化计算的过程,迭代地在xs
序列上执行合并,并将所有合并结果作为一棵树的输出值:
当然,可能有超过两个的归约需要合并。fold
的默认实现会将输入集合拆分成 512 个元素的块。我们的 166,000 元素的序列因此会生成 325 次归约以供合并。由于树形表示图占用的空间较大,我们将很快用尽页面的可用空间,因此让我们改为以更简洁的方式来可视化这个过程——作为一个两步的归约和合并过程。
第一步在集合中的所有块上执行并行归约。第二步则对中间结果执行串行归约,以得到最终结果:
上述表示展示了如何对多个 xs
序列进行归约,这里以圆圈表示,最终输出以方块表示。这些方块将串行合并,以产生最终结果,用星形表示。
使用 iota 加载大文件
在懒序列上调用 fold
需要 Clojure 将序列加载到内存中,然后将序列分块以进行并行执行。对于每行计算较小的情况,协调开销会超过并行化带来的好处。我们可以通过使用一个名为 iota
的库来稍微改善这种情况 (github.com/thebusby/iota
)。
注意
iota
库通过使用内存映射文件将文件直接加载到适合使用 reducers 进行折叠的数据结构中,可以处理比可用内存更大的文件。
将 iota
代替我们的 line-seq
函数后,我们的行数统计变成了:
(defn ex-5-7 []
(->> (iota/seq "data/soi.csv")
(r/fold + (fn [i x]
(inc i)))))
到目前为止,我们只处理了未格式化的行序列,但如果我们要做的不仅仅是计算行数,我们希望将其解析为更有用的数据结构。这是 Clojure 的 reducers 可以帮助我们提高代码效率的另一个领域。
创建一个 reducers 处理管道
我们已经知道文件是以逗号分隔的,因此让我们首先创建一个函数,将每行转换为一个字段向量。除了前两个字段,其他字段都包含数值数据,因此我们在此过程中将其解析为 double 类型:
(defn parse-double [x]
(Double/parseDouble x))
(defn parse-line [line]
(let [[text-fields double-fields] (->> (str/split line #",")
(split-at 2))]
(concat text-fields
(map parse-double double-fields))))
我们使用 map
的 reducers 版本,将我们的 parse-line
函数依次应用到文件中的每一行:
(defn ex-5-8 []
(->> (iota/seq "data/soi.csv")
(r/drop 1)
(r/map parse-line)
(r/take 1)
(into [])))
;; [("01" "AL" 0.0 1.0 889920.0 490850.0 ...)]
最终的 into
函数调用将 reducers 的内部表示(一个可归约的集合)转换为一个 Clojure 向量。上面的示例应该返回一个包含 77 个字段的序列,表示文件中第一行(头部后)的内容。
目前我们只是去掉了列名,但如果我们能利用这些列名返回每条记录的映射表示,将列名与字段值关联起来,那就太好了。映射的键将是列名,值将是解析后的字段。clojure.core
函数 zipmap
将从两个序列(一个用于键,一个用于值)创建一个映射:
(defn parse-columns [line]
(->> (str/split line #",")
(map keyword)))
(defn ex-5-9 []
(let [data (iota/seq "data/soi.csv")
column-names (parse-columns (first data))]
(->> (r/drop 1 data)
(r/map parse-line)
(r/map (fn [fields]
(zipmap column-names fields)))
(r/take 1)
(into []))))
此函数返回每行的映射表示,这是一种更加用户友好的数据结构:
[{:N2 1505430.0, :A19300 181519.0, :MARS4 256900.0 ...}]
Clojure 的 reducers 的一个很棒的功能是,在上述计算中, r/map
, r/drop
和 r/take
的调用被组合成一个减少,将在数据上进行单次遍历。随着操作数量的增加,这变得尤为重要。
假设我们想要过滤掉零邮政编码。我们可以扩展 reducer 管道如下:
(defn ex-5-10 []
(let [data (iota/seq "data/soi.csv")
column-names (parse-columns (first data))]
(->> (r/drop 1 data)
(r/map parse-line)
(r/map (fn [fields]
(zipmap column-names fields)))
(r/remove (fn [record]
(zero? (:zipcode record))))
(r/take 1)
(into []))))
r/remove
步骤现在也与 r/map
, r/drop
和 r/take
调用一起运行。随着数据量的增加,避免不必要地多次迭代数据变得越来越重要。使用 Clojure 的 reducers 确保我们的计算编译成单次遍历。
使用 reducers 的柯里化减少
为了使过程更加清晰,我们可以为之前的每个步骤创建一个 柯里化 版本。解析行,从字段创建记录并过滤掉零邮政编码。函数的柯里化版本是一个等待集合的减少:
(def line-formatter
(r/map parse-line))
(defn record-formatter [column-names]
(r/map (fn [fields]
(zipmap column-names fields))))
(def remove-zero-zip
(r/remove (fn [record]
(zero? (:zipcode record)))))
在每种情况下,我们调用 reducers 的函数之一,但没有提供集合。响应是函数的柯里化版本,稍后可以应用于集合。这些柯里化函数可以使用 comp
组合成单个 parse-file
函数:
(defn load-data [file]
(let [data (iota/seq file)
col-names (parse-columns (first data))
parse-file (comp remove-zero-zip
(record-formatter col-names)
line-formatter)]
(parse-file (rest data))))
只有当 parse-file
函数与序列一起调用时,管道才会实际执行。
使用 reducers 进行统计折叠
数据解析完成后,是时候进行一些描述性统计了。假设我们想知道 IRS 按邮政编码提交的*均退回数量(列 N1
)。有一种方法可以做到这一点——这是本书中多次尝试过的方法——通过将值加起来并除以计数来实现。我们的第一次尝试可能如下所示:
(defn ex-5-11 []
(let [data (load-data "data/soi.csv")
xs (into [] (r/map :N1 data))]
(/ (reduce + xs)
(count xs))))
;; 853.37
虽然这样做可以工作,但速度相对较慢。我们需要对数据进行三次迭代:一次创建 xs
,一次计算总和,一次计算数量。数据集越大,我们支付的时间成本越大。理想情况下,我们希望能够在单次数据遍历中计算均值,就像我们之前的 parse-file
函数一样。如果能并行执行更好。
结合性质
在继续之前,花点时间反思以下代码为什么不能达到我们想要的效果:
(defn mean
([] 0)
([x y] (/ (+ x y) 2)))
我们的 mean
函数是两个参数的函数。没有参数时,它返回零,是 mean
计算的单位元。有两个参数时,它返回它们的*均值:
(defn ex-5-12 []
(->> (load-data "data/soi.csv")
(r/map :N1)
(r/fold mean)))
;; 930.54
前面的示例对 N1
数据使用我们的 mean
函数进行了折叠,并产生了与之前不同的结果。如果我们能够扩展出前三个 xs
的计算,我们可能会看到类似以下代码的内容:
(mean (mean (mean 0 a) b) c)
这是一个不好的主意,因为 mean
函数不是关联的。对于关联函数,以下条件成立:
加法是结合律的,但乘法和除法不是。所以mean
函数也不是结合的。将mean
函数与以下简单加法进行对比:
(+ 1 (+ 2 3))
这与以下结果相同:
(+ (+ 1 2) 3)
+
的参数如何分区并不重要。结合性是用于对数据集进行归约的函数的一个重要特性,因为根据定义,先前计算的结果会作为下一个计算的输入。
将mean
函数转化为结合函数的最简单方法是分别计算和与计数。由于和与计数是结合的,它们可以并行地计算数据。mean
函数可以通过将两者相除来简单计算。
使用 fold 计算均值
我们将使用两个自定义函数mean-combiner
和mean-reducer
来创建一个 fold。这需要定义三个实体:
-
fold 的身份值
-
reduce 函数
-
combine 函数
我们在前一节中发现了结合律的好处,因此我们希望只使用结合操作来更新中间的mean
,并分别计算和与计数。表示这两个值的一种方式是一个包含:count
和:sum
两个键的映射。表示零的值就是和为零,计数为零,或者如下所示的映射:{:count 0 :sum 0}
。
combine 函数mean-combiner
在没有参数时会提供初始值。两个参数的 combiner 需要将每个参数的:count
和:sum
相加。我们可以通过使用+
来合并这些映射:
(defn mean-combiner
([] {:count 0 :sum 0})
([a b] (merge-with + a b)))
mean-reducer
函数需要接受一个累积值(无论是身份值还是先前计算结果),并将新的x
纳入其中。我们只需通过递增:count
并将x
加到累积的:sum
中来实现:
(defn mean-reducer [acc x]
(-> acc
(update-in [:count] inc)
(update-in [:sum] + x)))
前述两个函数已经足够完全定义我们的mean
fold:
(defn ex-5-13 []
(->> (load-data "data/soi.csv")
(r/map :N1)
(r/fold mean-combiner
mean-reducer)))
;; {:count 166598, :sum 1.4216975E8}
结果给出了我们计算N1
均值所需的所有信息,而这个计算只需一次遍历数据。计算的最后一步可以通过以下mean-post-combiner
函数来执行:
(defn mean-post-combiner [{:keys [count sum]}]
(if (zero? count) 0 (/ sum count)))
(defn ex-5-14 []
(->> (load-data "data/soi.csv")
(r/map :N1)
(r/fold mean-combiner
mean-reducer)
(mean-post-combiner)))
;; 853.37
幸运的是,结果与我们之前计算的均值一致。
使用 fold 计算方差
接下来,我们希望计算N1
值的方差。记住,方差是均值的*方差:
为了将其实现为 fold,我们可能会编写如下代码:
(defn ex-5-15 []
(let [data (->> (load-data "data/soi.csv")
(r/map :N1))
mean-x (->> data
(r/fold mean-combiner
mean-reducer)
(mean-post-combine))
sq-diff (fn [x] (i/pow (- x mean-x) 2))]
(->> data
(r/map sq-diff)
(r/fold mean-combiner
mean-reducer)
(mean-post-combine))))
;; 3144836.86
首先,我们使用刚才构建的 fold 计算该系列的mean
值。然后,我们定义一个关于x
和sq-diff
的函数,计算x
与mean
值之间的*方差。我们将其映射到*方差上,并再次调用mean
fold,最终得到方差结果。
因此,我们对数据进行了两次完整的遍历,首先计算均值,然后计算每个 x
与 mean
值的差异。看起来计算方差必然是一个顺序算法:似乎无法进一步减少步骤,只能通过单次遍历数据来计算方差。
事实上,方差计算可以用一个折叠表达。为此,我们需要追踪三项内容:计数、(当前)均值和*方差之和:
(defn variance-combiner
([] {:count 0 :mean 0 :sum-of-squares 0})
([a b]
(let [count (+ (:count a) (:count b))]
{:count count
:mean (/ (+ (* (:count a) (:mean a))
(* (:count b) (:mean b)))
count)
:sum-of-squares (+ (:sum-of-squares a)
(:sum-of-squares b)
(/ (* (- (:mean b)
(:mean a))
(- (:mean b)
(:mean a))
(:count a)
(:count b))
count))})))
我们的合并函数在前面的代码中展示。单位值是一个映射,所有三项的值都设为零。零元合并器返回这个值。
二元合并器需要合并两个传入值的计数、均值和*方和。合并计数很简单——我们只需要将它们相加。均值则稍微复杂一些:我们需要计算两个均值的加权*均。如果一个均值是基于较少的记录计算的,那么它在合并均值时应该占较少的份额:
合并*方和是最复杂的计算。合并*方和时,我们还需要添加一个因子,以考虑到 a
和 b
的*方和可能是基于不同的均值计算得出的:
(defn variance-reducer [{:keys [count mean sum-of-squares]} x]
(let [count' (inc count)
mean' (+ mean (/ (- x mean) count'))]
{:count count'
:mean mean'
:sum-of-squares (+ sum-of-squares
(* (- x mean') (- x mean)))}))
缩减器要简单得多,并解释了方差折叠如何通过一次数据扫描计算方差。对于每个新记录,mean
值是根据之前的 mean
和当前的 count
重新计算的。然后,我们将*方和增加为均值前后差异的乘积,考虑到这条新记录。
最终结果是一个包含 count
、mean
和总 sum-of-squares
的映射。由于方差只是 sum-of-squares
除以 count
,因此我们的 variance-post-combiner
函数是相对简单的:
(defn variance-post-combiner [{:keys [count mean sum-of-squares]}]
(if (zero? count) 0 (/ sum-of-squares count)))
将这三个函数结合起来,得到如下结果:
(defn ex-5-16 []
(->> (load-data "data/soi.csv")
(r/map :N1)
(r/fold variance-combiner
variance-reducer)
(variance-post-combiner)))
;; 3144836.86
由于标准差只是方差的*方根,因此我们只需要稍微修改过的 variance-post-combiner
函数,也可以计算标准差。
使用 Tesser 进行数学折叠
我们现在应该理解如何使用折叠来计算简单算法的并行实现。希望我们也能理解找到高效解决方案所需的巧思,这样能够在数据上进行最少次数的迭代。
幸运的是,Clojure 库 Tesser (github.com/aphyr/tesser
) 包含了常见数学折叠的实现,包括均值、标准差和协方差。为了了解如何使用 Tesser,我们考虑一下 IRS 数据集中两个字段的协方差:工资和薪金 A00200
,以及失业补偿 A02300
。
使用 Tesser 计算协方差
我们在第三章,相关性中遇到了协方差,它是用来衡量两组数据如何一起变化的指标。公式如下所示:
tesser.math
中包含了协方差 fold。在下面的代码中,我们将tesser.math
作为m
,tesser.core
作为t
来引用:
(defn ex-5-17 []
(let [data (into [] (load-data "data/soi.csv"))]
(->> (m/covariance :A02300 :A00200)
(t/tesser (t/chunk 512 data )))))
;; 3.496E7
m/covariance
函数期望接收两个参数:一个返回x
值的函数,另一个返回y
值的函数。由于关键字作为提取相应值的函数,我们只需将关键字作为参数传递。
Tesser 的工作方式类似于 Clojure 的 reducers,但有一些小差异。Clojure 的fold
负责将我们的数据分割为子序列进行并行执行。而在 Tesser 中,我们必须显式地将数据分成小块。由于这是我们将要反复做的事情,让我们创建一个名为chunks
的辅助函数:
(defn chunks [coll]
(->> (into [] coll)
(t/chunk 1024)))
在本章的大部分内容中,我们将使用chunks
函数将输入数据拆分为1024
条记录的小组。
交换律
Clojure 的 reducers 与 Tesser 的 folds 之间的另一个区别是,Tesser 不保证输入顺序会被保持。除了是结合律的,如我们之前讨论的,Tesser 的函数还必须满足交换律。交换律函数是指无论其参数的顺序如何变化,结果都相同的函数:
加法和乘法是交换律的,但减法和除法不是。交换律是分布式数据处理函数的一个有用属性,因为它减少了子任务之间协调的需求。当 Tesser 执行合并函数时,它可以在任何先返回结果的 reducer 函数上进行,而不需要等到第一个完成。
让我们将load-data
函数改写为prepare-data
函数,它将返回一个交换律的 Tesser fold。它执行与我们之前基于 reducer 的函数相同的步骤(解析文本文件中的一行,格式化记录为 map 并删除零 ZIP 代码),但它不再假设列头是文件中的第一行——first是一个明确要求有序数据的概念:
(def column-names
[:STATEFIPS :STATE :zipcode :AGI_STUB :N1 :MARS1 :MARS2 ...])
(defn prepare-data []
(->> (t/remove #(.startsWith % "STATEFIPS"))
(t/map parse-line)
(t/map (partial format-record column-names))
(t/remove #(zero? (:zipcode %)))))
现在,所有的准备工作都已在 Tesser 中完成,我们可以直接将iota/seq
的结果作为输入。这在本章后面我们将代码分布式运行在 Hadoop 时尤其有用:
(defn ex-5-18 []
(let [data (iota/seq "data/soi.csv")]
(->> (prepare-data)
(m/covariance :A02300 :A00200)
(t/tesser (chunks data)))))
;; 3.496E7
在第三章,相关性中,我们看到在简单线性回归中,当只有一个特征和一个响应变量时,相关系数是协方差与标准差乘积的比值:
Tesser 还包括计算一对属性相关性的函数,作为一个 fold 来实现:
(defn ex-5-19 []
(let [data (iota/seq "data/soi.csv")]
(->> (prepare-data)
(m/correlation :A02300 :A00200)
(t/tesser (chunks data)))))
;; 0.353
这两个变量之间存在适度的正相关。让我们构建一个线性模型,用工资和薪金(A00200
)来预测失业补偿(A02300
)的值。
使用 Tesser 进行简单线性回归
Tesser 当前没有提供线性回归的 fold,但它提供了我们实现线性回归所需的工具。在第三章相关性中,我们看到如何通过输入的方差、协方差和均值来计算简单线性回归模型的系数——斜率和截距:
斜率 b 是协方差除以 X 的方差。截距是确保回归线穿过两个序列均值的值。因此,理想情况下,我们能够在数据的单个 fold 中计算出这四个变量。Tesser 提供了两个 fold 组合器,t/fuse
和 t/facet
,用于将更基本的 folds 组合成更复杂的 folds。
在我们有一个输入记录并且需要并行运行多个计算的情况下,我们应该使用 t/fuse
。例如,在以下示例中,我们将均值和标准差的 fold 融合为一个单独的 fold,同时计算这两个值:
(defn ex-5-20 []
(let [data (iota/seq "data/soi.csv")]
(->> (prepare-data)
(t/map :A00200)
(t/fuse {:A00200-mean (m/mean)
:A00200-sd (m/standard-deviation)})
(t/tesser (chunks data)))))
;; {:A00200-sd 89965.99846545042, :A00200-mean 37290.58880658831}
在这里,我们需要对映射中的所有字段进行相同的计算;因此,我们应该使用 t/facet
:
(defn ex-5-21 []
(let [data (iota/seq "data/soi.csv")]
(->> (prepare-data)
(t/map #(select-keys % [:A00200 :A02300]))
(t/facet)
(m/mean)
(t/tesser (chunks data)))))
;; {:A02300 419.67862159209596, :A00200 37290.58880658831}
在前面的代码中,我们只选择了记录中的两个值(A00200
和 A02300
),并同时计算了它们的 mean
值。回到执行简单线性回归的挑战——我们有四个数值需要计算,因此我们将它们 fuse
(结合)起来:
(defn calculate-coefficients [{:keys [covariance variance-x
mean-x mean-y]}]
(let [slope (/ covariance variance-x)
intercept (- mean-y (* mean-x slope))]
[intercept slope]))
(defn ex-5-22 []
(let [data (iota/seq "data/soi.csv")
fx :A00200
fy :A02300]
(->> (prepare-data)
(t/fuse {:covariance (m/covariance fx fy)
:variance-x (m/variance (t/map fx))
:mean-x (m/mean (t/map fx))
:mean-y (m/mean (t/map fx))})
(t/post-combine calculate-coefficients)
(t/tesser (chunks data)))))
;; [37129.529236553506 0.0043190406799462925]
fuse
非常简洁地将我们想要执行的计算结合在一起。此外,它允许我们指定一个 post-combine
步骤,作为 fuse 的一部分。我们可以直接将其作为 fold 的一个整体来指定,而不是将结果交给另一个函数来完成输出。post-combine
步骤接收四个结果,并从中计算出斜率和截距,返回两个系数作为一个向量。
计算相关性矩阵
我们只比较了两个特征,看看它们之间的相关性,但 Tesser 使得查看多个目标特征之间的相互关联变得非常简单。我们将目标特征提供为一个映射,其中特征名称对应于输入记录中某个函数,这个函数返回所需的特征。例如,在第三章相关性中,我们本来会取身高的对数。在这里,我们将简单地提取每个特征,并为它们提供易于理解的名称:
(defn ex-5-23 []
(let [data (iota/seq "data/soi.csv")
attributes {:unemployment-compensation :A02300
:salary-amount :A00200
:gross-income :AGI_STUB
:joint-submissions :MARS2
:dependents :NUMDEP}]
(->> (prepare-data)
(m/correlation-matrix attributes)
(t/tesser (chunks data)))))
Tesser 将计算每对特征之间的相关性,并将结果以映射的形式返回。映射以包含每对特征名称的元组(两个元素的向量)为键,相关值为它们之间的相关性。
如果你现在运行前面的示例,你会发现某些变量之间有较高的相关性。例如,:dependents
和:unemployment-compensation
之间的相关性为0.821
。让我们建立一个线性回归模型,将所有这些变量作为输入。
梯度下降的多元回归
当我们在第三章,相关性中运行多元线性回归时,我们使用了正规方程和矩阵来快速得出多元线性回归模型的系数。正规方程如下重复:
正规方程使用矩阵代数来非常快速且高效地得出最小二乘估计。当所有数据都能装入内存时,这是一个非常方便且简洁的方程。然而,当数据超出单台机器可用的内存时,计算变得笨重。原因在于矩阵求逆。计算并不是可以在数据上折叠一次完成的——输出矩阵中的每个单元格都依赖于输入矩阵中的许多其他单元格。这些复杂的关系要求矩阵必须以非顺序的方式处理。
解决线性回归问题以及许多其他相关机器学习问题的另一种方法是梯度下降技术。梯度下降将问题重新构造为一个迭代算法的解决方案——这个算法并不是在一个计算量极大的步骤中计算答案,而是通过一系列更小的步骤逐渐接*正确答案。
在上一章中,我们遇到了梯度下降,当时我们使用了 Incanter 的minimize
函数来计算出使我们逻辑回归分类器成本最低的参数。随着数据量的增加,Incanter 不再是执行梯度下降的可行方案。在下一节中,我们将看到如何使用 Tesser 自行运行梯度下降。
梯度下降更新规则
梯度下降通过反复应用一个函数来工作,这个函数将参数朝着其最优值的方向移动。为了应用这个函数,我们需要知道当前参数下的成本函数的梯度。
计算梯度公式涉及微积分内容,超出了本书的范围。幸运的是,最终的公式并不难理解:
是我们代价函数J(β)相对于索引j的参数的偏导数或梯度。因此,我们可以看到,代价函数相对于索引j的参数的梯度等于我们预测值与真实值y之间的差乘以索引j的特征值x。
因为我们希望沿着梯度下降,所以我们希望从当前的参数值中减去梯度的某个比例。因此,在每一步梯度下降中,我们会执行以下更新:
这里,:=
是赋值操作符,α是一个叫做学习率的因子。学习率控制我们希望在每次迭代中根据梯度对参数进行调整的大小。如果我们的预测ŷ几乎与实际值y相符,那么对参数的调整就不大。相反,较大的误差会导致对参数进行更大的调整。这个规则被称为Widrow-Hoff 学习规则或Delta 规则。
梯度下降学习率
正如我们所见,梯度下降是一个迭代算法。学习率,通常用α表示,决定了梯度下降收敛到最终答案的速度。如果学习率太小,收敛过程会非常缓慢。如果学习率过大,梯度下降就无法找到接*最优值的结果,甚至可能会从正确答案发散出去:
在前面的图表中,一个较小的学习率会导致算法在多次迭代中收敛得非常慢。虽然算法最终能够达到最小值,但它需要经过比理想情况更多的步骤,因此可能会花费相当长的时间。相反,在下图中,我们可以看到一个过大学习率的效果。参数估计在每次迭代之间变化如此剧烈,以至于它们实际上超出了最优值,并且从最小值开始发散:
梯度下降算法要求我们在数据集上反复迭代。在选择正确的α值后,每次迭代应该会逐步提供更接*理想参数的估计值。当迭代之间的变化非常小,或者达到了预定的迭代次数时,我们可以选择终止算法。
特征缩放
随着更多特征被添加到线性模型中,适当缩放特征变得非常重要。如果特征的尺度差异非常大,梯度下降的表现会很差,因为无法为所有特征选择一个合适的学习率。
我们可以执行的简单缩放是从每个值中减去均值并除以标准差。这将使得值趋于零均值,通常在-3
和3
之间波动:
(defn feature-scales [features]
(->> (prepare-data)
(t/map #(select-keys % features))
(t/facet)
(t/fuse {:mean (m/mean)
:sd (m/standard-deviation)})))
上述代码中的feature-factors
函数使用t/facet
来计算所有输入特征的mean
值和标准差:
(defn ex-5-24 []
(let [data (iota/seq "data/soi.csv")
features [:A02300 :A00200 :AGI_STUB :NUMDEP :MARS2]]
(->> (feature-scales features)
(t/tesser (chunks data)))))
;; {:MARS2 {:sd 533.4496892658647, :mean 317.0412009748016}...}
如果你运行上述示例,你会看到feature-scales
函数返回的不同均值和标准差。由于我们的特征缩放和输入记录表示为 maps,我们可以使用 Clojure 的merge-with
函数一次性对所有特征进行缩放:
(defn scale-features [factors]
(let [f (fn [x {:keys [mean sd]}]
(/ (- x mean) sd))]
(fn [x]
(merge-with f x factors))))
同样,我们可以使用unscale-features
进行至关重要的反转操作:
(defn unscale-features [factors]
(let [f (fn [x {:keys [mean sd]}]
(+ (* x sd) mean))]
(fn [x]
(merge-with f x factors))))
让我们对特征进行缩放并查看第一个特征。Tesser 不允许我们在没有 reducer 的情况下执行折叠操作,因此我们将暂时恢复使用 Clojure 的 reducers:
(defn ex-5-25 []
(let [data (iota/seq "data/soi.csv")
features [:A02300 :A00200 :AGI_STUB :NUMDEP :MARS2]
factors (->> (feature-scales features)
(t/tesser (chunks data)))]
(->> (load-data "data/soi.csv")
(r/map #(select-keys % features ))
(r/map (scale-features factors))
(into [])
(first))))
;; {:MARS2 -0.14837567114357617, :NUMDEP 0.30617757526890155,
;; :AGI_STUB -0.714280814223704, :A00200 -0.5894942801950217,
;; :A02300 0.031741856083514465}
这一步骤将帮助梯度下降在我们的数据上进行优化。
特征提取
尽管我们在本章中使用 maps 来表示输入数据,但在运行梯度下降时,将特征表示为矩阵会更加方便。让我们编写一个函数,将输入数据转换为一个包含xs
和y
的 map。y
轴将是标量响应值,xs
将是缩放后的特征值矩阵。
如前几章所示,我们将偏置项添加到返回的特征矩阵中:
(defn feature-matrix [record features]
(let [xs (map #(% record) features)]
(i/matrix (cons 1 xs))))
(defn extract-features [fy features]
(fn [record]
{:y (fy record)
:xs (feature-matrix record features)}))
我们的feature-matrix
函数仅接受一个记录输入和要转换为矩阵的特征。我们在extract-features
中调用此函数,该函数返回一个可以应用于每个输入记录的函数:
(defn ex-5-26 []
(let [data (iota/seq "data/soi.csv")
features [:A02300 :A00200 :AGI_STUB :NUMDEP :MARS2]
factors (->> (feature-scales features)
(t/tesser (chunks data)))]
(->> (load-data "data/soi.csv")
(r/map (scale-features factors))
(r/map (extract-features :A02300 features))
(into [])
(first))))
;; {:y 433.0, :xs A 5x1 matrix
;; -------------
;; 1.00e+00
;; -5.89e-01
;; -7.14e-01
;; 3.06e-01
;; -1.48e-01
;; }
上述示例展示了数据被转换为适合进行梯度下降的格式:一个包含y
响应变量的 map 和包含偏置项的值矩阵。
创建自定义 Tesser 折叠
梯度下降的每次迭代都会根据成本函数确定的值调整系数。成本函数是通过对数据集中每个参数的误差求和计算得出的,因此,拥有一个对矩阵元素进行逐项求和的折叠函数将非常有用。
而 Clojure 通过 reducer、combiner 以及从 combiner 获得的身份值来表示折叠,Tesser 的折叠则通过六个协作函数来表达。Tesser 的m/mean
折叠的实现如下:
{:reducer-identity (constantly [0 0])
:reducer (fn reducer [[s c] x]
[(+ s x) (inc c)])
:post-reducer identity
:combiner-identity (constantly [0 0])
:combiner (fn combiner [x y] (map + x y))
:post-combiner (fn post-combiner [x]
(double (/ (first x)
(max 1 (last x)))))}
Tesser 选择将reducer
身份与combiner
函数分开表示,并且还包含另外三个函数:combiner-identity
、post-reducer
和post-combiner
函数。Tesser 的mean
折叠将一对数字(计数和累积和)表示为两个数字的向量,但在其他方面,它与我们自己的折叠类似。
我们已经看到如何使用post-combiner
函数,与我们在本章前面提到的mean-post-combiner
和variance-post-combiner
函数配合使用。
创建一个矩阵求和折叠
要创建一个自定义的matrix-sum
折叠,我们需要一个单位值。我们在第三章《相关性》中遇到过单位矩阵,但这是矩阵乘法的单位矩阵,而不是加法的单位矩阵。如果+
的单位值是零(因为将零加到一个数字上不会改变它),那么矩阵加法的单位矩阵就是一个全零矩阵。
我们必须确保矩阵的大小与我们想要相加的矩阵相同。所以,让我们使用矩阵的行数和列数来参数化我们的matrix-sum
折叠。我们无法提前知道需要多大,因为单位函数会在折叠中的任何操作之前被调用:
(defn matrix-sum [nrows ncols]
(let [zeros-matrix (i/matrix 0 nrows ncols)]
{:reducer-identity (constantly zeros-matrix)
:reducer i/plus
:combiner-identity (constantly zeros-matrix)
:combiner i/plus}))
上面的示例是完整的matrix-sum
折叠定义。我们没有提供post-combiner
和post-reducer
函数;因为如果省略这些,默认它们是单位函数,这正是我们想要的。我们可以使用新的折叠来计算输入中所有特征的总和,如下所示:
(defn ex-5-27 []
(let [columns [:A02300 :A00200 :AGI_STUB :NUMDEP :MARS2]
data (iota/seq "data/soi.csv")]
(->> (prepare-data)
(t/map (extract-features :A02300 columns))
(t/map :xs)
(t/fold (matrix-sum (inc (count columns)) 1))
(t/tesser (chunks data)))))
;; A 6x1 matrix
;; -------------
;; 1.67e+05
;; 6.99e+07
;; 6.21e+09
;; ...
;; 5.83e+05
;; 9.69e+07
;; 5.28e+07
计算矩阵的总和让我们更接*执行梯度下降的目标。让我们使用新的折叠计算总模型误差,前提是我们有一些初始系数。
计算总模型误差
让我们再看一看梯度下降的 delta 规则:
对于每个参数* j ,我们根据总体预测误差 ŷ - y *的某个比例来调整该参数,并乘以特征。因此,较大的特征比较小的特征承担更多的成本,并相应地调整更大的量。为了在代码中实现这一点,我们需要计算:
这是所有输入记录中特征与预测误差乘积的总和。正如我们之前所做的那样,我们的预测值* y 将使用以下公式为每个输入记录 x *计算:
系数β在所有输入记录中都是相同的,因此让我们创建一个calculate-error
函数。给定转置的系数β^T,我们返回一个函数来计算!计算总模型误差。由于* x 是一个矩阵, ŷ - y *是一个标量,结果将是一个矩阵:
(defn calculate-error [coefs-t]
(fn [{:keys [y xs]}]
(let [y-hat (first (i/mmult coefs-t xs))
error (- y-hat y)]
(i/mult xs error))))
为了计算整个数据集的误差总和,我们可以简单地在calculate-error
步骤后连接我们之前定义的matrix-sum
函数:
(defn ex-5-28 []
(let [columns [:A02300 :A00200 :AGI_STUB :NUMDEP :MARS2]
fcount (inc (count columns))
coefs (vec (replicate fcount 0))
data (iota/seq "data/soi.csv")]
(->> (prepare-data)
(t/map (extract-features :A02300 columns))
(t/map (calculate-error (i/trans coefs)))
(t/fold (matrix-sum fcount 1))
(t/tesser (chunks data)))))
;; A 6x1 matrix
;; -------------
;; -6.99e+07
;; -2.30e+11
;; -8.43e+12
;; ...
;; -1.59e+08
;; -2.37e+11
;; -8.10e+10
请注意,所有特征的梯度都是负的。这意味着,为了下降梯度并生成更好的模型系数估计,必须增加参数。
创建一个矩阵均值折叠
前面的代码中定义的更新规则实际上要求将代价的*均值分配给每一个特征。这意味着我们需要计算sum
和count
。我们不想对数据执行两次单独的遍历。因此,正如我们之前做的那样,我们将这两个折叠合并成一个:
(defn ex-5-29 []
(let [columns [:A02300 :A00200 :AGI_STUB :NUMDEP :MARS2]
fcount (inc (count columns))
coefs (vec (replicate fcount 0))
data (iota/seq "data/soi.csv")]
(->> (prepare-data)
(t/map (extract-features :A02300 columns))
(t/map (calculate-error (i/trans coefs)))
(t/fuse {:sum (t/fold (matrix-sum fcount 1))
:count (t/count)})
(t/post-combine (fn [{:keys [sum count]}]
(i/div sum count)))
(t/tesser (chunks data)))))
fuse
函数将返回:sum
和:count
的映射,因此我们将在结果上调用post-combine
。post-combine
函数指定了一个在折叠结束时运行的函数,该函数简单地将总和除以计数。
另外,我们可以创建另一个自定义折叠,返回一个矩阵序列的均值,而不是总和。它与之前定义的matrix-sum
折叠有很多相似之处,但与我们在本章前面计算的mean
折叠一样,我们还需要跟踪处理过的记录数:
(defn matrix-mean [nrows ncols]
(let [zeros-matrix (i/matrix 0 nrows ncols)]
{:reducer-identity (constantly [zeros-matrix 0])
:reducer (fn [[sum counter] x]
[(i/plus sum x) (inc counter)])
:combiner-identity (constantly [zeros-matrix 0])
:combiner (fn [[sum-a count-a] [sum-b count-b]]
[(i/plus sum-a sum-b)
(+ count-a count-b)])
:post-combiner (fn [[sum count]]
(i/div sum count))}))
减少器的恒等式是一个包含[zeros-matrix 0]
的向量。每次减少都会将值添加到矩阵总和中,并将计数器加一。每个合并步骤都会将两个矩阵以及它们的计数相加,以得出所有分区的总和和总计数。最后,在post-combiner
步骤中,均值被计算为sum
与count
的比值。
尽管自定义折叠的代码比我们合并的sum
和count
解决方案要更长,但我们现在有了一种计算矩阵均值的一般方法。这使得示例更加简洁易读,而且我们可以像这样在误差计算代码中使用它:
(defn ex-5-30 []
(let [features [:A02300 :A00200 :AGI_STUB :NUMDEP :MARS2]
fcount (inc (count features))
coefs (vec (replicate fcount 0))
data (iota/seq "data/soi.csv")]
(->> (prepare-data)
(t/map (extract-features :A02300 features))
(t/map (calculate-error (i/trans coefs)))
(t/fold (matrix-mean fcount 1))
(t/tesser (chunks data)))))
;; A 5x1 matrix
;; -------------
;; 4.20e+01
;; 3.89e+01
;; -3.02e+01
;; 9.02e+01
;; 6.62e+01
创建自定义折叠的小额额外努力,使得调用代码的意图变得更容易理解。
应用梯度下降的单步
计算代价的目标是确定调整每个系数的幅度。一旦我们计算出*均代价,正如我们之前所做的那样,我们需要更新对系数β的估计。这些步骤合起来代表梯度下降的单次迭代:
我们可以在post-combiner
步骤中返回更新后的系数,该步骤利用*均代价、alpha 的值和之前的系数。我们来创建一个实用函数update-coefficients
,它将接收系数和 alpha,并返回一个函数,该函数将在给定总模型代价的情况下计算新的系数:
(defn update-coefficients [coefs alpha]
(fn [cost]
(->> (i/mult cost alpha)
(i/minus coefs))))
在前面的函数到位后,我们就有了将批量梯度下降更新规则打包的所有必要工具:
(defn gradient-descent-fold [{:keys [fy features factors
coefs alpha]}]
(let [zeros-matrix (i/matrix 0 (count features) 1)]
(->> (prepare-data)
(t/map (scale-features factors))
(t/map (extract-features fy features))
(t/map (calculate-error (i/trans coefs)))
(t/fold (matrix-mean (inc (count features)) 1))
(t/post-combine (update-coefficients coefs alpha)))))
(defn ex-5-31 []
(let [features [:A00200 :AGI_STUB :NUMDEP :MARS2]
fcount (inc (count features))
coefs (vec (replicate fcount 0))
data (chunks (iota/seq "data/soi.csv"))
factors (->> (feature-scales features)
(t/tesser data))
options {:fy :A02300 :features features
:factors factors :coefs coefs :alpha 0.1}]
(->> (gradient-descent-fold options)
(t/tesser data))))
;; A 6x1 matrix
;; -------------
;; -4.20e+02
;; -1.38e+06
;; -5.06e+07
;; -9.53e+02
;; -1.42e+06
;; -4.86e+05
结果矩阵表示梯度下降第一次迭代后的系数值。
运行迭代梯度下降
梯度下降是一个迭代算法,通常需要运行多次才能收敛。对于一个大型数据集,这可能非常耗时。
为了节省时间,我们在数据目录中包含了一个名为soi-sample.csv
的soi.csv
随机样本。较小的文件大小使得我们能够在合理的时间尺度内运行迭代梯度下降。以下代码执行 100 次梯度下降迭代,并绘制每次迭代之间的参数值在xy-plot
上的变化:
(defn descend [options data]
(fn [coefs]
(->> (gradient-descent-fold (assoc options :coefs coefs))
(t/tesser data))))
(defn ex-5-32 []
(let [features [:A00200 :AGI_STUB :NUMDEP :MARS2]
fcount (inc (count features))
coefs (vec (replicate fcount 0))
data (chunks (iota/seq "data/soi-sample.csv"))
factors (->> (feature-scales features)
(t/tesser data))
options {:fy :A02300 :features features
:factors factors :coefs coefs :alpha 0.1}
iterations 100
xs (range iterations)
ys (->> (iterate (descend options data) coefs)
(take iterations))]
(-> (c/xy-plot xs (map first ys)
:x-label "Iterations"
:y-label "Coefficient")
(c/add-lines xs (map second ys))
(c/add-lines xs (map #(nth % 2) ys))
(c/add-lines xs (map #(nth % 3) ys))
(c/add-lines xs (map #(nth % 4) ys))
(i/view))))
如果你运行这个示例,你应该会看到一个类似于下图的图表:
在上面的图表中,你可以看到在 100 次迭代过程中,参数是如何趋向相对稳定的值的。
使用 Hadoop 扩展梯度下降
批量梯度下降每次迭代运行所需的时间由数据大小和计算机的处理器数量决定。尽管多个数据块是并行处理的,但数据集很大,且处理器是有限的。通过并行计算,我们已经实现了速度提升,但如果我们将数据集的大小翻倍,运行时间也会翻倍。
Hadoop 是过去十年中出现的多个系统之一,旨在并行化超出单台机器能力的工作。Hadoop 并不是将代码分发到多个处理器上执行,而是负责在多个服务器上运行计算。实际上,Hadoop 集群可以,也确实有很多,包含成千上万的服务器。
Hadoop 由两个主要子系统组成——Hadoop 分布式文件系统(HDFS)——和作业处理系统MapReduce。HDFS 将文件存储为块。一个文件可能由许多块组成,且这些块通常会在多个服务器之间进行复制。通过这种方式,Hadoop 能够存储任何单一服务器无法处理的庞大数据量,并且通过复制确保数据在硬件故障时能够可靠存储。正如其名所示,MapReduce 编程模型围绕 map 和 reduce 步骤构建。每个作业至少包含一个 map 步骤,并可以选择性地指定一个 reduce 步骤。一个完整的作业可能由多个 map 和 reduce 步骤串联而成。
在 reduce 步骤是可选的这一点上,Hadoop 相比 Tesser 在分布式计算方面有一个稍微更灵活的方法。在本章以及未来章节中,我们将进一步探讨 Hadoop 提供的更多功能。Tesser 确实使我们能够将折叠转换为 Hadoop 作业,接下来我们就来做这个。
使用 Tesser 和 Parkour 在 Hadoop 上运行梯度下降
Tesser 的 Hadoop 功能可以在tesser.hadoop
命名空间中找到,我们将其包含为h
。Hadoop 命名空间中的主要公共 API 函数是h/fold
。
fold
函数期望接收至少四个参数,分别代表 Hadoop 作业的配置、我们想要处理的输入文件、Hadoop 用于存储临时文件的工作目录,以及我们希望执行的 fold
,它被引用为 Clojure var
。任何额外的参数将在执行时作为参数传递给 fold
。
使用 var
来表示我们的 fold
的原因是,启动 fold
的函数调用可能发生在与实际执行它的计算机完全不同的计算机上。在分布式环境中,var
和参数必须完全指定函数的行为。我们通常不能依赖其他可变的局部状态(例如,一个原子变量的值,或闭包内的变量值)来提供任何额外的上下文。
Parkour 分布式源和接收器
我们希望 Hadoop 作业处理的数据可能也存在于多台机器上,并且在 HDFS 上分块分布。Tesser 使用一个名为 Parkour 的库(github.com/damballa/parkour/
)来处理访问可能分布式的数据源。我们将在本章和下一章更详细地学习 Parkour,但目前,我们只会使用 parkour.io.text
命名空间来引用输入和输出文本文件。
虽然 Hadoop 设计为在多个服务器上运行和分布式执行,但它也可以在 本地模式 下运行。本地模式适用于测试,并使我们能够像操作 HDFS 一样与本地文件系统交互。我们将从 Parkour 中使用的另一个命名空间是 parkour.conf
。这将允许我们创建一个默认的 Hadoop 配置,并在本地模式下运行它:
(defn ex-5-33 []
(->> (text/dseq "data/soi.csv")
(r/take 2)
(into [])))
在前面的示例中,我们使用 Parkour 的 text/dseq
函数创建了一个 IRS 输入数据的表示。返回值实现了 Clojure 的 reducers 协议,因此我们可以对结果使用 r/take
。
使用 Hadoop 运行功能规模的 fold
Hadoop 在执行任务时需要一个位置来写入其临时文件,如果我们尝试覆盖现有目录,它会发出警告。由于在接下来的几个示例中我们将执行多个作业,让我们创建一个小的工具函数,返回一个带有随机生成名称的新文件。
(defn rand-file [path]
(io/file path (str (long (rand 0x100000000)))))
(defn ex-5-34 []
(let [conf (conf/ig)
input (text/dseq "data/soi.csv")
workdir (rand-file "tmp")
features [:A00200 :AGI_STUB :NUMDEP :MARS2]]
(h/fold conf input workdir #'feature-scales features)))
Parkour 提供了一个默认的 Hadoop 配置对象,使用简写(conf/ig
)。它将返回一个空的配置。默认值已经足够,我们不需要提供任何自定义配置。
注意
我们的所有 Hadoop 作业将把临时文件写入项目 tmp
目录中的一个随机目录。如果你担心磁盘空间,可以稍后删除这个文件夹。
如果你现在运行前面的示例,你应该会得到类似以下的输出:
;; {:MARS2 317.0412009748016, :NUMDEP 581.8504423822615,
;; :AGI_STUB 3.499939975269811, :A00200 37290.58880658831}
尽管返回值与我们之前获得的值相同,但我们现在在幕后使用 Hadoop 来处理我们的数据。尽管如此,注意 Tesser 将从我们的折叠操作返回一个单一的 Clojure 数据结构作为响应。
使用 Hadoop 运行梯度下降
由于tesser.hadoop
折叠返回的 Clojure 数据结构与tesser.core
折叠类似,定义一个利用我们缩放后的特征的梯度下降函数非常简单:
(defn hadoop-gradient-descent [conf input-file workdir]
(let [features [:A00200 :AGI_STUB :NUMDEP :MARS2]
fcount (inc (count features))
coefs (vec (replicate fcount 0))
input (text/dseq input-file)
options {:column-names column-names
:features features
:coefs coefs
:fy :A02300
:alpha 1e-3}
factors (h/fold conf input (rand-file workdir)
#'feature-scales
features)
descend (fn [coefs]
(h/fold conf input (rand-file workdir)
#'gradient-descent-fold
(merge options {:coefs coefs
:factors factors})))]
(take 5 (iterate descend coefs))))
上述代码定义了一个hadoop-gradient-descent
函数,该函数迭代执行descend
函数5
次。每次descend
迭代都会基于gradient-descent-fold
函数计算改进后的系数。最终返回值是梯度下降5
次后的系数向量。
我们在以下示例中运行整个 IRS 数据的作业:
(defn ex-5-35 []
(let [workdir "tmp"
out-file (rand-file workdir)]
(hadoop-gradient-descent (conf/ig) "data/soi.csv" workdir)))
在经过几次迭代后,你应该看到类似以下的输出:
;; ([0 0 0 0 0]
;; (20.9839310796048 46.87214911003046 -7.363493937722712
;; 101.46736841329326 55.67860863427868)
;; (40.918665605227744 56.55169901254631 -13.771345753228694
;; 162.1908841131747 81.23969785586247)
;; (59.85666340457121 50.559130068258995 -19.463888245285332
;; 202.32407094149158 92.77424653758085)
;; (77.8477613139478 38.67088624825574 -24.585818946408523
;; 231.42399118694212 97.75201693843269))
我们已经看到如何使用本地的分布式技术计算梯度下降。现在,让我们看看如何在我们自己的集群上运行这个过程。
为 Hadoop 集群准备我们的代码
Hadoop 的 Java API 定义了Tool
和相关的ToolRunner
类,用于帮助在 Hadoop 集群上执行任务。Tool
类是 Hadoop 为通用命令行应用程序定义的名称,它与 Hadoop 框架进行交互。通过创建我们自己的工具,我们就创建了一个可以提交到 Hadoop 集群的命令行应用程序。
由于这是一个 Java 框架,Hadoop 期望与我们代码的类表示进行交互。因此,定义我们工具的命名空间需要包含:gen-class
声明,指示 Clojure 编译器从我们的命名空间创建一个类:
(ns cljds.ch5.hadoop
(:gen-class)
...)
默认情况下,:gen-class
将期望命名空间定义一个名为-main
的主函数。这将是 Hadoop 用我们的参数调用的函数,因此我们可以简单地将调用委托给一个实际执行我们任务的函数:
(defn -main [& args]
(tool/run hadoop-gradient-descent args))
Parkour 提供了一个 Clojure 接口,用于与 Hadoop 的许多类进行交互。在这种情况下,parkour.tool/run
包含了我们在 Hadoop 上运行分布式梯度下降函数所需的一切。通过上述示例,我们需要指示 Clojure 编译器提前(AOT)编译我们的命名空间,并指定我们希望将项目的主类定义为哪个类。我们可以通过将:aot
和:main
声明添加到project.clj
函数中来实现:
{:main cljds.ch5.hadoop
:aot [cljds.ch5.hadoop]}
在示例代码中,我们将这些设置为:uberjar
配置的一部分,因为在将作业发送到集群之前,我们的最后一步是将其打包为 uberjar 文件。
构建 uberjar
JAR 文件包含可执行的 Java 代码。一个 uberjar 文件包含可执行的 Java 代码,以及运行所需的所有依赖项。uberjar 提供了一种方便的方式来打包代码,以便在分布式环境中运行,因为作业可以在机器之间传递,同时携带它的依赖项。虽然这会导致较大的作业负载,但它避免了确保所有集群中的机器预先安装特定作业依赖项的需要。要使用Leiningen创建 uberjar 文件,在项目目录下执行以下命令行:
lein uberjar
完成此操作后,目标目录中将创建两个文件。一个文件ch5-0.1.0.jar
包含项目的编译代码。这与使用lein jar
生成的文件相同。此外,uberjar 会生成ch5-0.1.0-standalone.jar
文件。这个文件包含了项目代码的 AOT 编译版本以及项目的依赖项。生成的文件虽然较大,但它包含了 Hadoop 作业运行所需的所有内容。
将 uberjar 提交到 Hadoop
一旦我们创建了一个 uberjar 文件,就可以将其提交给 Hadoop。拥有一个本地的 Hadoop 安装并不是跟随本章示例的前提条件,我们也不会在此描述安装步骤。
注意
Hadoop 安装指南的链接已提供在本书的 wiki 页面:wiki.clojuredatascience.com
。
然而,如果你已经在本地模式下安装并配置了 Hadoop,现在就可以在命令行上运行示例作业。由于主类指定的工具也接受两个参数——工作目录和输入文件——因此这些参数也需要提供:
hadoop jar target/ch5-0.1.0-standalone.jar data/soi.csv tmp
如果命令成功运行,你可能会看到 Hadoop 进程输出的日志信息。经过一段时间,你应该能看到作业输出的最终系数。
尽管目前执行需要更多时间,但我们的 Hadoop 作业有一个优势,那就是它可以分布在一个可以随着数据量不断扩展的集群上。
随机梯度下降
我们刚才看到的梯度下降计算方法通常被称为批量梯度下降,因为每次对系数的更新都是在对所有数据进行单次批量迭代的过程中完成的。对于大量数据来说,每次迭代可能会非常耗时,等待收敛可能需要很长时间。
梯度下降的另一种方法叫做随机梯度下降或SGD。在这种方法中,系数的估算会随着输入数据的处理不断更新。随机梯度下降的更新方法如下:
实际上,这与批量梯度下降是完全相同的。应用的不同之处纯粹在于表达式 是在 小批次——即数据的随机子集——上计算的。小批次的大小应该足够大,以便代表输入记录的公*样本——对于我们的数据,一个合理的小批次大小可能是 250。
随机梯度下降通过将整个数据集分成小批次并逐个处理它们来获得最佳估计。由于每个小批次的输出就是我们希望用于下一个小批次的系数(以便逐步改进估计),因此该算法本质上是顺序的。
随机梯度下降相较于批量梯度下降的优势在于,它可以在对数据集进行一次迭代后就获得良好的估计。对于非常大的数据集,甚至可能不需要处理所有小批次,就能达到良好的收敛效果。
我们可以通过利用组合器串行应用的事实,使用 Tesser 实现 SGD,并将每个块视为一个小批次,从中可以计算出系数。这意味着我们的归约步骤就是恒等函数——我们不需要进行任何归约操作。
相反,让我们利用这个机会来学习如何在 Parkour 中构建一个 Hadoop 作业。在深入了解 Parkour 之前,让我们先看看如何使用我们已经掌握的知识实现随机梯度下降:
(defn stochastic-gradient-descent [options data]
(let [batches (->> (into [] data)
(shuffle)
(partition 250))
descend (fn [coefs batch]
(->> (gradient-descent-fold
(assoc options :coefs coefs))
(t/tesser (chunks batch))))]
(reductions descend (:coefs options) batches)))
上述代码将输入集合分成较小的 250 元素组。对每个小批次运行梯度下降并更新系数。梯度下降的下一次迭代将在下一个批次上使用新的系数,并且对于适当的 alpha 值,生成改进的推荐结果。
以下代码将在数百个批次中绘制输出:
(defn ex-5-36 []
(let [features [:A00200 :AGI_STUB :NUMDEP :MARS2]
fcount (inc (count features))
coefs (vec (replicate fcount 0))
data (chunks (iota/seq "data/soi.csv"))
factors (->> (feature-scales features)
(t/tesser data))
options {:fy :A02300 :features features
:factors factors :coefs coefs :alpha 1e-3}
ys (stochastic-gradient-descent options data)
xs (range (count ys))]
(-> (c/xy-plot xs (map first ys)
:x-label "Iterations"
:y-label "Coefficient")
(c/add-lines xs (map #(nth % 1) ys))
(c/add-lines xs (map #(nth % 2) ys))
(c/add-lines xs (map #(nth % 3) ys))
(c/add-lines xs (map #(nth % 4) ys))
(i/view))))
我们提供的学习率比批量梯度下降的值小 100 倍。这有助于确保包含离群点的小批次不会使参数偏离其最优值。由于每个小批次固有的方差,随机梯度下降的输出将不会完全收敛到最优参数,而是会围绕最小值波动。
上面的图片展示了随机梯度下降更为随机的效果;特别是小批次之间方差对参数估计的影响。尽管学习率大大降低,我们仍能看到与包含离群点的数据批次相对应的尖峰。
使用 Parkour 的随机梯度下降
在本章的其余部分,我们将直接使用 Parkour 构建 Hadoop 作业。Parkour 比 Tesser 更加暴露 Hadoop 的底层能力,这既是优点也是缺点。尽管 Tesser 使得在 Hadoop 中编写 fold 操作并应用于大型数据集变得非常容易,但 Parkour 需要我们更多地理解 Hadoop 的计算模型。
尽管 Hadoop 的 MapReduce 方法体现了我们在本章中遇到的许多原则,但它与 Tesser 的抽象在几个关键方面有所不同:
-
Hadoop 假定待处理的数据是键/值对
-
Hadoop 不要求在 map 之后进行 reduce 阶段
-
Tesser 在整个输入序列上进行折叠,Hadoop 在各组数据上进行归约
-
Hadoop 的值组由分区器定义
-
Tesser 的 combine 阶段发生在 reduce 之后,Hadoop 的 combine 阶段发生在 reduce 之前
最后一项特别令人遗憾。我们为 Clojure reducer 和 Tesser 学到的术语,在 Hadoop 中被反转了:在 Hadoop 中,combiner 会在数据被发送到 reducer 之前聚合 mapper 的输出。
我们可以在以下图示中看到广泛的流程,其中 mapper 的输出被合并成中间表示并在发送到 reducers 之前进行排序。每个 reducer 对整个数据的子集进行归约。combine 步骤是可选的,事实上,在我们的随机梯度下降作业中我们不需要一个:
无论是否有 combine 步骤,数据都会在发送到 reducers 之前按组进行排序,分组策略由分区器定义。默认的分区方案是按键/值对的键进行分区(不同的键在前面的图示中由不同的灰度表示)。事实上,也可以使用任何自定义的分区方案。
如你所见,Parkour 和 Hadoop 都不假定输出是单一结果。由于 Hadoop 的 reduce 操作默认通过分组键定义的组进行,reduce 的结果可以是一个包含多个记录的数据集。在前面的图示中,我们为数据中的每个键展示了三个不同结果的情况。
定义 mapper
我们将定义的 Hadoop 作业的第一个组件是 mapper。mapper 的角色通常是接受一块输入记录并以某种方式转换它们。可以指定一个没有 reducer 的 Hadoop 作业;在这种情况下,mapper 的输出就是整个作业的输出。
Parkour 允许我们将映射器的操作定义为 Clojure 函数。该函数的唯一要求是,它需要将输入数据(无论是来自源文件,还是来自前一个 MapReduce 步骤的输出)作为最后一个参数传入。如果需要,还可以提供额外的参数,只要输入数据是最后一个参数:
(defn parse-m
{::mr/source-as :vals
::mr/sink-as :vals}
[fy features factors lines]
(->> (skip-header lines)
(r/map parse-line)
(r/map (partial format-record column-names))
(r/map (scale-features factors))
(r/map (extract-features fy features))
(into [])
(shuffle)
(partition 250)))
前面的例子中,map
函数中的 parse-m
(按照惯例,Parkour 映射器的后缀是 -m
)负责将输入的单行数据转换成特征表示。我们重新使用了本章前面定义的许多函数:parse-line
、format-record
、scale-features
和 extract-features
。Parkour 将以可简化集合的形式向映射器函数提供输入数据,因此我们将函数通过 r/map
进行链式调用。
随机梯度下降需要按小批量处理数据,因此我们的映射器负责将数据划分为 250 行的小组。在调用 partition
之前,我们进行 shuffle
,以确保数据的顺序是随机的。
Parkour 造型函数
我们还向 parse-m
函数提供了元数据,形式为 {::mr/source-as :vals ::mr/sink-as :vals}
的映射。这两个命名空间关键字分别引用 parkour.mapreduce/source-as
和 parkour.mapreduce/sink-as
,它们是指示 Parkour 在将数据提供给函数之前应该如何构造数据以及它可以期望返回的数据的结构。
Parkour 映射器的有效选项有 :keyvals
、:keys
和 :vals
。前面的图示展示了三个键/值对的短序列的效果。通过请求将数据源作为 :vals
,我们得到一个仅包含键/值对中值部分的序列。
定义一个归约器
在 Parkour 中定义一个归约器与定义映射器相同。最后一个参数必须是输入(现在是来自先前映射步骤的输入),但可以提供额外的参数。我们的随机梯度下降的 Parkour 归约器如下所示:
(defn sum-r
{::mr/source-as :vals
::mr/sink-as :vals}
[fcount alpha batches]
(let [initial-coefs (vec (replicate fcount 0))
descend-batch (fn [coefs batch]
(->> (t/map (calculate-error
(i/trans coefs)))
(t/fold (matrix-mean fcount 1))
(t/post-combine
(update-coefficients coefs alpha))
(t/tesser (chunks batch))))]
(r/reduce descend-batch initial-coefs batches)))
我们的输入数据与之前一样作为可简化集合提供,因此我们使用 Clojure 的 reducers 库来进行迭代。我们使用 r/reduce
而不是 r/fold
,因为我们不想对数据进行并行的归约处理。实际上,使用 Hadoop 的原因是我们可以独立控制映射阶段和归约阶段的并行度。现在,我们已经定义了映射和归约步骤,可以通过使用 parkour.graph
命名空间中的函数将它们组合成一个作业。
使用 Parkour 图定义 Hadoop 作业
graph
命名空间是 Parkour 用来定义 Hadoop 作业的主要 API。每个作业至少需要一个输入、一个映射器和一个输出,我们可以通过 Clojure 的 ->
宏将这些规格链式连接。首先定义一个非常简单的作业,它从我们的映射器获取输出并立即写入磁盘:
(defn hadoop-extract-features [conf workdir input output]
(let [fy :A02300
features [:A00200 :AGI_STUB :NUMDEP :MARS2]
fcount (inc (count features))
input (text/dseq input)
factors (h/fold conf input (rand-file workdir)
#'feature-scales
features)
conf (conf/ig)]
(-> (pg/input input)
(pg/map #'parse-m fy features factors)
(pg/output (text/dsink output))
(pg/execute conf "extract-features-job"))))
(defn ex-5-37 []
(let [workdir "tmp"
out-file (rand-file workdir)]
(hadoop-extract-features (conf/ig) "tmp"
"data/soi.csv" out-file)
(str out-file)))
;; "tmp/1935333306"
前面的示例返回的响应应该是项目 tmp
目录中的一个子目录,Hadoop 会将文件写入该目录。如果你进入该目录,你应该会看到几个文件。在我的电脑上,我看到了四个文件——_SUCCESS
、part-m-00000
、part-m-00001
和 part-m-00002
。_SUCCESS
文件的存在表示我们的作业已成功完成。part-m-xxxxx
文件是输入文件的块。
文件数量为三,说明 Hadoop 创建了三个映射器来处理我们的输入数据。如果我们以分布式模式运行,可能会并行创建这些文件。如果你打开其中一个文件,你应该看到一长串 clojure.lang.LazySeq@657d118e
。由于我们写入的是文本文件,所以这是我们映射器输出的文本表示。
使用 Parkour 图链式连接映射器和 reducers
我们真正想做的是将映射和减少步骤链式连接,使它们一个接一个地执行。为此,我们必须插入一个中间步骤,分区器,并告诉分区器如何序列化我们的 clojure.lang.LazySeqs
。
后者可以通过借用 Tesser 来实现,Tesser 实现了使用 Fressian 对任意 Clojure 数据结构进行序列化和反序列化的功能。在下一章中,我们将更深入地了解 Parkour 如何提供支持,帮助我们为分区数据创建良定义的架构,但现在仅仅让分区器通过编码后的数据就足够了。
注意
Fressian 是一种可扩展的二进制数据格式。你可以通过 github.com/clojure/data.fressian
的文档了解更多信息。
我们的键将编码为 FressianWritable
,而我们的键没有明确指定(我们像处理 vals
一样处理我们的映射数据)。Hadoop 对 nil
的表示是 NullWritable
类型。我们通过以下方式将两者导入到我们的命名空间中:
(:import [org.apache.hadoop.io NullWritable]
[tesser.hadoop_support FressianWritable])
在导入完成后,我们可以完全指定我们的作业:
(defn hadoop-sgd [conf workdir input-file output]
(let [kv-classes [NullWritable FressianWritable]
fy :A02300
features [:A00200 :AGI_STUB :NUMDEP :MARS2]
fcount (inc (count features))
input (text/dseq input-file)
factors (h/fold conf input (rand-file workdir)
#'feature-scales
features)
conf (conf/assoc! conf "mapred.reduce.tasks" 1)]
(-> (pg/input input)
(pg/map #'parse-m fy features factors)
(pg/partition kv-classes)
(pg/reduce #'sum-r fcount 1e-8)
(pg/output (text/dsink output))
(pg/execute conf "sgd-job"))))
我们需要确保只有一个 reducer 来处理我们的迷你批次(尽管 SGD 有一些变种允许我们*均多个随机梯度下降的结果,但我们希望得到一组接*最优的系数)。我们将使用 Parkour 的 conf
命名空间,通过 assoc! mapred.reduce.tasks
设置为 1
。
在映射和减少步骤之间,我们指定分区器并传递在函数顶部定义的 kv-classes
函数。最后的示例仅仅运行这个作业:
(defn ex-5-38 []
(let [workdir "tmp"
out-file (rand-file workdir)]
(hadoop-sgd (conf/ig) "tmp" "data/soi.csv" out-file)
(str out-file)))
;; "tmp/4046267961"
如果你进入作业返回的目录,你应该现在看到一个只包含两个文件的目录——_SUCCESS
和 part-r-00000
。每个文件是一个 reducer 的输出,因此在只有一个 reducer 的情况下,我们最终得到了一个 part-r-xxxxx
文件。这个文件内部包含了使用随机梯度下降计算的线性模型系数。
总结
在本章中,我们学习了一些分布式数据处理的基本技术,并了解了本地用于数据处理的函数——映射(map)和归约(reduce),它们是处理即使是非常大量数据的强大方法。我们了解了 Hadoop 如何通过在数据的较小子集上运行函数,来扩展超越任何单一服务器的能力,这些子集的输出最终会组合起来生成结果。一旦你理解了其中的权衡,这种“分而治之”的数据处理方法,就是一种简单且非常通用的大规模数据分析方式。
我们看到了简单折叠操作在使用 Clojure 的 reducers 和 Tesser 时处理数据的强大功能和局限性。我们还开始探索 Parkour 如何暴露 Hadoop 更深层的能力。
在下一章,我们将看到如何使用 Hadoop 和 Parkour 来解决一个特定的机器学习挑战——对大量文本文档进行聚类。
第六章 聚类
具有共同特质的事物总会很快地寻找到它们自己的同类。 | ||
---|---|---|
--马库斯·奥勒留 |
在前几章中,我们涵盖了多个学习算法:线性回归和逻辑回归,C4.5,朴素贝叶斯和随机森林。在每种情况下,我们需要通过提供特征和期望输出来训练算法。例如,在线性回归中,期望输出是奥运游泳选手的体重,而对于其他算法,我们提供了一个类别:乘客是否生还。这些是监督学习算法的示例:我们告诉算法期望的输出,它将尝试学习生成相似的模型。
还有一类学习算法称为无监督学习。无监督算法能够在没有参考答案集的情况下操作数据。我们甚至可能不知道数据内部的结构;算法将尝试自行确定这种结构。
聚类是无监督学习算法的一个例子。聚类分析的结果是输入数据的分组,这些数据在某种方式上更相似于彼此。该技术是通用的:任何具有概念上相似性或距离的实体都可以进行聚类。例如,我们可以通过共享粉丝的相似性来聚类社交媒体账户组,或者通过测量受访者问卷答案的相似性来聚类市场调研的结果。
聚类的一个常见应用是识别具有相似主题的文档。这为我们提供了一个理想的机会来讨论文本处理,本章将介绍一系列处理文本的特定技术。
下载数据
本章使用Reuters-21578数据集:这是一组在 1987 年通过路透社新闻线发布的文章。它是最广泛用于测试文本分类和分类的数据集之一。文章文本和 Reuters-21578 集合中的注释版权归 Reuters Ltd.所有。Reuters Ltd.和 Carnegie Group, Inc.已同意仅为研究目的免费分发这些数据。
注意
您可以从 Packt Publishing 的网站或github.com/clojuredatascience/ch6-clustering
下载本章的示例代码。
如往常一样,在示例代码中有一个脚本用于下载并解压文件到数据目录。您可以在项目目录中使用以下命令运行它:
script/download-data.sh
另外,在写作时,Reuters 数据集可以从kdd.ics.uci.edu/databases/reuters21578/reuters21578.tar.gz
下载。本章的其余部分将假设文件已被下载并安装到项目的data
目录中。
提取数据
运行前面的脚本后,文章将被解压到目录data/reuters-sgml
中。每个.sgm
文件包含大约 1,000 篇短文章,这些文章使用标准通用标记语言(SGML)被包装在 XML 样式的标签中。我们不需要自己编写解析器,而是可以利用已经在 Lucene 文本索引器中写好的解析器。
(:import [org.apache.lucene.benchmark.utils ExtractReuters])
(defn sgml->txt [in-path out-path]
(let [in-file (clojure.java.io/file in-path)
out-file (clojure.java.io/file out-path)]
(.extract (ExtractReuters. in-file out-file))))
在这里,我们利用 Clojure 的 Java 互操作性,简单地调用 Lucene 的ExtractReuters
类中的提取方法。每篇文章都被提取为一个独立的文本文件。
可以通过执行以下代码运行:
lein extract-reuters
在项目目录内的命令行中运行。输出将是一个新目录data/reuters-text
,其中包含超过 20,000 个独立的文本文件。每个文件包含一篇单独的路透社新闻文章。
如果磁盘空间不足,现在可以删除reuters-sgml
和reuters21578.tar.gz
文件:reuters-text
目录中的内容是本章中我们唯一会使用的文件。现在让我们看看其中的几个文件。
检查数据
1987 年是“黑色星期一”那一年。10 月 19 日,全球股市暴跌,道琼斯工业*均指数下降了 508 点,降至 1738.74。像reut2-020.sgm-962.txt
中包含的文章描述了这一事件:
19-OCT-1987 16:14:37.57
WALL STREET SUFFERS WORST EVER SELLOFF
Wall Street tumbled to its worst point loss ever and the worst percentage decline since the First World War as a frenzy of stock selling stunned even the most bearish market participants. "Everyone is in awe and the word 'crash' is on everyone's mind," one trader said. The Dow Jones industrial average fell 508 points to 1738, a level it has not been at since the Autumn of 1986\. Volume soared to 603 mln shares, almost doubling the previous record of 338 mln traded just last Friday. Reuter 
这篇文章的结构代表了语料库中大多数文章的结构。第一行是时间戳,表示文章的发布时间,后面是一个空行。文章有一个标题,通常——但并非总是——是大写字母,然后是另一个空行。最后是文章正文文本。与处理此类半结构化文本时常见的情况一样,文章中存在多个空格、奇怪的字符和缩写。
其他文章只是标题,例如在reut2-020.sgm-761.txt
中:
20-OCT-1987 17:09:34.49
REAGAN SAYS HE SEES NO RECESSION
这些文件是我们将进行聚类分析的对象。
聚类文本
聚类是找到彼此相似的对象组的过程。目标是,簇内的对象应该比簇外的对象更加相似。与分类一样,聚类并不是一个特定的算法,而是解决一般问题的算法类别。
尽管有多种聚类算法,但它们都在某种程度上依赖于距离度量。为了让算法判断两个对象是否属于同一簇或不同簇,它必须能够确定它们之间的距离(或者,如果你愿意,可以认为是相似度)的定量度量。这就需要一个数字化的距离度量:距离越小,两个对象之间的相似度就越大。
由于聚类是一种可以应用于多种数据类型的通用技术,因此有大量可能的距离度量方法。尽管如此,大多数数据都可以通过一些常见的抽象表示:集合、空间中的点或向量。对于这些,每种情况都有一种常用的度量方法。
单词集合与 Jaccard 指数
如果你的数据可以表示为事物的集合,则可以使用 Jaccard 指数,也称为Jaccard 相似度。它在概念上是最简单的度量之一:它是集合交集除以集合并集,或者是集合中共同元素的数量占总独特元素数量的比例:
许多事物可以表示为集合。社交网络上的账户可以表示为朋友或关注者的集合,顾客可以表示为购买或查看过的商品集合。对于我们的文本文件,集合表示可以简单地是使用的唯一单词的集合。
Jaccard 指数在 Clojure 中非常简单计算:
(:require [clojure.set :as set])
(defn jaccard-similarity [a b]
(let [a (set a)
b (set b)]
(/ (count (set/intersection a b))
(count (set/union a b)))))
(defn ex-6-1 []
(let [a [1 2 3]
b [2 3 4]]
(jaccard a b)))
;; => 1/2
它的优点是,即使集合的基数不同,距离度量依然有意义。在前面的图示中,A“比”B要“大”,但交集除以并集仍然能公*地反映它们的相似度。要将 Jaccard 指数应用于文本文件,我们需要将它们转化为单词集合。这就是词元化的过程。
词元化 Reuters 文件
词元化是将一串文本拆分成更小的单元以便进行分析的技术名称。常见的方法是将文本字符串拆分成单个单词。一个明显的分隔符是空格,因此 "tokens like these"
会变成 ["tokens" "like" "these"]
。
(defn tokenize [s]
(str/split s #"\W+"))
这既方便又简单,但不幸的是,语言是微妙的,很少有简单的规则可以普遍适用。例如,我们的词元化器将撇号视为空格:
(tokenize "doesn't handle apostrophes")
;; ["doesn" "t" "handle" "apostrophes"]
连字符也被视为空格:
(tokenize "good-looking user-generated content")
;; ["good" "looking" "user" "generated" "content"]
而删除它们则会改变句子的含义。然而,并非所有的连字符都应该被保留:
(tokenize "New York-based")
;; ["New" "York" "based"]
"New"
、"York"
和 "based"
正确地表示了短语的主体,但最好将 "New York"
归为一个词,因为它代表了一个特定的名称,应该完整保留。另一方面,York-based
单独作为词元没有意义。
简而言之,文本是杂乱的,从自由文本中可靠地解析出意义是一个极其丰富和活跃的研究领域。特别是,对于从文本中提取名称(例如,“纽约”),我们需要考虑术语使用的上下文。通过其语法功能标注句子中词汇的技术被称为词性标注器。
注意
如需了解更多关于高级分词和词性标注的信息,请参见clojure-opennlp
库:github.com/dakrone/clojure-opennlp
。
在本章中,我们有足够多的文档可以使用,因此我们将继续使用我们的简单分词器。我们会发现,尽管它存在一些缺点,它仍然能够足够好地从文档中提取意义。
让我们编写一个函数,根据文档的文件名返回文档的标记:
(defn tokenize-reuters [content]
(-> (str/replace content #"^.*\n\n" "")
(str/lower-case)
(tokenize)))
(defn reuters-terms [file]
(-> (io/resource file)
(slurp)
(tokenize-reuters)))
我们正在去除文件顶部的时间戳,并在分词之前将文本转换为小写。在下一部分,我们将看到如何衡量分词后的文档相似性。
应用 Jaccard 指数到文档上
在对输入文档进行分词之后,我们可以将结果的标记序列简单地传递给我们之前定义的jaccard-similarity
函数。让我们比较一下来自路透社语料库的几篇文档相似性:
(defn ex-6-2 []
(let [a (set (reuters-terms "reut2-020.sgm-761.txt"))
b (set (reuters-terms "reut2-007.sgm-750.txt"))
s (jaccard a b)]
(println "A:" a)
(println "B:" b)
(println "Similarity:" s)))
A: #{recession says reagan sees no he}
B: #{bill transit says highway reagan and will veto he}
Similarity: 1/4
Jaccard 指数输出一个介于零和一之间的数字,因此它认为这两篇文档在标题中的词汇相似度为 25%。注意我们丢失了标题中词汇的顺序。没有我们稍后会讲到的其他技巧,Jaccard 指数只关注两个集合中共同的项目。我们丢失的另一个方面是一个词在文档中出现的次数。重复出现同一个词的文档,可能会在某种意义上视该词为更重要。例如,reut2-020.sgm-932.txt
的标题如下:
19-OCT-1987 16:41:40.58
NYSE CHAIRMAN JOHN PHELAN SAYS NYSE WILL OPEN TOMORROW ON TIME
NYSE 在标题中出现了两次。我们可以推测这个标题特别关注纽约证券交易所,可能比只提到一次 NYSE 的标题更为重要。
词袋模型和欧几里得距离
一种可能优于词集方法的改进是词袋模型方法。这种方法保留了文档中各个词汇的词频。通过距离度量可以将词频纳入考虑,从而可能得到更准确的结果。
最常见的距离概念之一是欧几里得距离度量。在几何学中,欧几里得度量是我们计算空间中两点之间距离的方式。在二维空间中,欧几里得距离由毕达哥拉斯公式给出:
这表示两个点之间的差异是它们之间直线距离的长度。
这可以扩展到三维:
并且推广到 n 维度:
其中 A[i] 和 B[i] 是在维度 i 上 A 或 B 的值。因此,距离度量就是两个文档之间的整体相似性,考虑了每个单词出现的频率。
(defn euclidean-distance [a b]
(->> (map (comp i/sq -) a b)
(apply +)
(i/sqrt)))
由于每个单词现在代表空间中的一个维度,我们需要确保在计算欧几里得距离时,我们是在比较每个文档中同一维度上的大小。否则,我们可能会字面意义上比较“苹果”和“橙子”。
将文本表示为向量
与 Jaccard 指数不同,欧几里得距离依赖于将单词一致地排序为各个维度。词频(term frequency)表示文档在一个大型多维空间中的位置,我们需要确保在比较值时,比较的是正确维度中的值。我们将文档表示为术语频率向量。
想象一下,文档中可能出现的所有单词都被赋予一个唯一的编号。例如,单词“apple”可以被赋值为 53,单词“orange”可以被赋值为 21,597。如果所有数字都是唯一的,它们可以对应于单词在术语向量中出现的索引。
这些向量的维度可能非常大。最大维度数是向量的基数。对应于单词的索引位置的元素值通常是该单词在文档中出现的次数。这被称为术语频率(tf)加权。
为了能够比较文本向量,重要的是相同的单词始终出现在向量中的相同索引位置。这意味着我们必须为每个创建的向量使用相同的单词/索引映射。这个单词/索引映射就是我们的词典。
创建词典
为了创建一个有效的词典,我们需要确保两个单词的索引不会冲突。做到这一点的一种方法是使用一个单调递增的计数器,每添加一个单词到词典中,计数器就增加一次。单词被添加时的计数值将成为该单词的索引。为了线程安全地同时将单词添加到词典并递增计数器,我们可以使用原子操作:
(def dictionary
(atom {:count 0
:words {}}))
(defn add-term-to-dict [dict word]
(if (contains? (:terms dict) word)
dict
(-> dict
(update-in [:terms] assoc word (get dict :count))
(update-in [:count] inc))))
(defn add-term-to-dict! [dict term]
(doto dict
(swap! add-term-to-dict term)))
为了对原子进行更新,我们必须在swap!
函数中执行我们的代码。
(add-term-to-dict! dictionary "love")
;; #<Atom@261d1f0a: {:count 1, :terms {"love" 0}}>
添加另一个单词将导致计数增加:
(add-term-to-dict! dictionary "music")
;; #<Atom@261d1f0a: {:count 2, :terms {"music" 1, "love" 0}}>
并且重复添加相同单词不会产生任何效果:
(add-term-to-dict! dictionary "love")
;; #<Atom@261d1f0a: {:count 2, :terms {"music" 1, "love" 0}}>
在原子操作中执行此更新可以确保即使在多个线程同时更新词典时,每个单词也能获得自己的索引。
(defn build-dictionary! [dict terms]
(reduce add-term-to-dict! dict terms))
构建整个词典就像在提供的词典原子上使用add-term-to-dict!
函数并对一组术语进行简化操作一样简单。
创建术语频率向量
为了计算欧几里得距离,首先让我们从字典和文档中创建一个向量。这将使我们能够轻松地比较文档之间的术语频率,因为它们将占据向量的相同索引。
(defn term-id [dict term]
(get-in @dict [:terms term]))
(defn term-frequencies [dict terms]
(->> (map #(term-id dict %) terms)
(remove nil?)
(frequencies)))
(defn map->vector [dictionary id-counts]
(let [zeros (vec (replicate (:count @dictionary) 0))]
(-> (reduce #(apply assoc! %1 %2) (transient zeros) id-counts)
(persistent!))))
(defn tf-vector [dict document]
(map->vector dict (term-frequencies dict document)))
term-frequencies
函数为文档中的每个术语创建一个术语 ID 到频率计数的映射。map->vector
函数简单地接受此映射,并在由术语 ID 给出的向量索引位置关联频率计数。由于可能有许多术语,且向量可能非常长,因此我们使用 Clojure 的 transient!
和 persistent!
函数暂时创建一个可变向量以提高效率。
让我们打印 reut2-020.sgm-742.txt
的文档、字典和生成的向量:
(defn ex-6-3 []
(let [doc (reuters-terms "reut2-020.sgm-742.txt")
dict (build-dictionary! dictionary doc)]
(println "Document:" doc)
(println "Dictionary:" dict)
(println "Vector:" (tf-vector dict doc))))
输出结果如下所示(格式已调整以提高可读性):
;; Document: [nyse s phelan says nyse will continue program
;; trading curb until volume slows]
;; Dictionary: #<Atom@bb156ec: {:count 12, :terms {s 1, curb 8,
;; phelan 2, says 3, trading 7, nyse 0, until 9,
;; continue 5, volume 10, will 4, slows 11,
;; program 6}}>
;; Vector: [2 1 1 1 1 1 1 1 1 1 1 1]
输入中有 12 个术语,字典中有 12 个术语,并返回了一个包含 12 个元素的向量。
(defn print-distance [doc-a doc-b measure]
(let [a-terms (reuters-terms doc-a)
b-terms (reuters-terms doc-b)
dict (-> dictionary
(build-dictionary! a-terms)
(build-dictionary! b-terms))
a (tf-vector dict a-terms)
b (tf-vector dict b-terms)]
(println "A:" a)
(println "B:" b)
(println "Distance:" (measure a b))))
(defn ex-6-4 []
(print-distance "reut2-020.sgm-742.txt"
"reut2-020.sgm-932.txt"
euclidean-distance))
;; A: [2 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0]
;; B: [2 0 1 1 1 0 0 0 0 0 0 0 1 1 1 1 1 1]
;; Distance: 3.7416573867739413
与 Jaccard 指数类似,欧几里得距离不能低于零。然而,不同于 Jaccard 指数,欧几里得距离的值可以无限增长。
向量空间模型与余弦距离
向量空间模型可以视为词集(set-of-words)和词袋(bag-of-words)模型的推广。与词袋模型类似,向量空间模型将每个文档表示为一个向量,每个元素表示一个术语。每个索引位置的值是该词的重要性度量,可能是也可能不是术语频率。
如果你的数据从概念上表示一个向量(也就是说,一个特定方向上的大小),那么余弦距离可能是最合适的选择。余弦距离度量通过计算两个元素的向量表示之间夹角的余弦值来确定它们的相似度。
如果两个向量指向相同的方向,那么它们之间的夹角为零,而零的余弦值为 1。余弦相似度可以通过以下方式定义:
这是一个比我们之前讨论的方程更为复杂的方程。它依赖于计算两个向量的点积及其大小。
(defn cosine-similarity [a b]
(let [dot-product (->> (map * a b)
(apply +))
magnitude (fn [d]
(->> (map i/sq d)
(apply +)
(i/sqrt)))]
(/ dot-product (* (magnitude a) (magnitude b)))))
余弦相似度的例子如下所示:
余弦相似度通常作为高维空间中的相似度度量使用,其中每个向量包含很多零,因为它计算起来非常高效:只需要考虑非零维度。由于大多数文本文档只使用了所有单词中的一小部分(因此在大多数维度上是零),余弦度量通常用于文本聚类。
在向量空间模型中,我们需要一个一致的策略来衡量每个词项的重要性。在词集合模型中,所有词项被*等对待。这相当于将该点的向量值设置为 1。在词袋模型中,计算了词项的频率。我们目前将继续使用词频,但很快我们将看到如何使用一种更复杂的重要性度量,称为词频-逆文档频率(TF-IDF)。
(defn ex-6-5 []
(print-distance "reut2-020.sgm-742.txt"
"reut2-020.sgm-932.txt"
cosine-similarity))
;; A: [2 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0]
;; B: [2 0 1 1 1 0 0 0 0 0 0 0 1 1 1 1 1 1]
;; Distance: 0.5012804118276031
余弦值越接*1
,这两个实体越相似。为了将余弦相似度
转化为距离度量,我们可以简单地将余弦相似度
从1
中减去。
尽管前面提到的所有度量方法对相同输入产生不同的度量值,但它们都满足一个约束,即* A 和 B 之间的距离应该与 B 和 A *之间的差异相同。通常,相同的底层数据可以被转换为表示集合(Jaccard)、空间中的一个点(欧几里得)或一个向量(余弦)。有时,唯一的方法是尝试并查看结果如何,才能知道哪个是正确的。
出现在一个文档中的唯一单词数量通常比处理中的文档集合中任何文档中出现的唯一单词数量要少。因此,这些高维文档向量通常是非常稀疏的。
去除停用词
许多头条新闻之间的相似性是由一些经常出现的词汇所产生的,这些词汇对内容的意义贡献不大。例如,“a”,“says”和“and”。我们应该过滤掉这些词,以避免产生虚假的相似性。
考虑以下两个习语:
-
"音乐是爱的食粮"
-
"战争是历史的动力"
我们可以使用以下 Clojure 代码来计算它们之间的余弦相似度:
(defn ex-6-6 []
(let [a (tokenize "music is the food of love")
b (tokenize "war is the locomotive of history")]
(add-documents-to-dictionary! dictionary [a b])
(cosine-similarity (tf-vector dictionary a)
(tf-vector dictionary b))))
;; 0.5
尽管这两个文档之间唯一的共同单词是is
、the
和of
,它们的相似度为0.5
。理想情况下,我们希望移除这些词。
词干提取
现在让我们考虑一个替代表达:
-
"音乐是爱的食粮"
-
"你很有音乐天赋,真好"
让我们也来比较它们的余弦相似度:
(defn ex-6-7 []
(let [a (tokenize "music is the food of love")
b (tokenize "it's lovely that you're musical")]
(add-documents-to-dictionary! dictionary [a b])
(cosine-similarity (tf-vector dictionary a)
(tf-vector dictionary b))))
;; 0.0
尽管这两个句子都提到音乐和积极情感,但这两个短语的余弦相似度为零:两个短语之间没有共同的单词。这是有道理的,但并没有表达我们通常想要的行为,即捕捉“概念”之间的相似性,而不是精确使用的词汇。
解决这个问题的一种方法是词干提取,它将单词简化为其词根。具有共同意义的单词更可能提取到相同的词根。Clojure 库的词干提取器(github.com/mattdw/stemmers
)可以为我们完成这项工作,幸运的是,它们也会去除停用词。
(defn ex-6-8 []
(let [a (stemmer/stems "music is the food of love")
b (stemmer/stems "it's lovely that you're musical")]
(add-documents-to-dictionary! dictionary [a b])
(cosine-similarity (tf-vector dictionary a)
(tf-vector dictionary b))))
;; 0.8164965809277259
好得多了。经过词干提取和去除停用词后,短语之间的相似度从 0.0 降低到 0.82。这是一个很好的结果,因为尽管句子使用了不同的词汇,它们表达的情感是相关的。
使用 k-means 和 Incanter 进行聚类
最终,在对输入文档进行标记化、词干提取和向量化处理,并且选择了不同的距离度量方式后,我们可以开始对数据进行聚类。我们将首先探讨的聚类算法是 k-means 聚类。
k-means 是一个迭代算法,步骤如下:
-
随机选择 k 个聚类中心点。
-
将每个数据点分配到与其最*的聚类中心点所属的聚类中。
-
调整每个聚类中心点的位置,使其位于其分配数据点的均值位置。
-
重复进行,直到收敛或达到最大迭代次数。
该过程在以下图示中展示,适用于 k=3 个聚类:
在上图中,我们可以看到,在第一次迭代时,初始的聚类中心点并没有很好地代表数据的结构。尽管这些点显然被分成了三组,但初始的聚类中心点(以十字标记表示)都分布在图形的上方区域。这些点的颜色表示它们与最*的聚类中心的关系。随着迭代的进行,我们可以看到聚类中心点如何逐步靠*每个点组的“自然”位置,即组中心。
在定义主要的 k-means 函数之前,先定义几个实用的辅助函数是有用的:一个用于计算聚类中心点的函数,另一个则是将数据分组到各自聚类的函数。
(defn centroid [xs]
(let [m (i/trans (i/matrix xs))]
(if (> (i/ncol m) 1)
(i/matrix (map s/mean m))
m)))
(defn ex-6-9 []
(let [m (i/matrix [[1 2 3]
[2 2 5]])]
(centroid m)))
;; A 3x1 matrix
;; -------------
;; 1.50e+00
;; 2.00e+00
;; 4.00e+00
centroid
函数简单地计算输入矩阵每列的均值。
(defn clusters [cluster-ids data]
(->> (map vector cluster-ids data)
(conj-into {})
(vals)
(map i/matrix)))
(defn ex-6-10 []
(let [m (i/matrix [[1 2 3]
[4 5 6]
[7 8 9]])]
(clusters [0 1 0] m)))
;; A 1x3 matrix
;; -------------
;; 4.00e+00 5.00e+00 6.00e+00
;; A 2x3 matrix
;; -------------
;; 7.00e+00 8.00e+00 9.00e+00
;; 1.00e+00 2.00e+00 3.00e+00
clusters
函数根据提供的聚类 ID 将较大的矩阵拆分为一系列较小的矩阵。聚类 ID 被提供为与聚类点长度相同的元素序列,列出该序列中每个点对应的聚类 ID。共享相同聚类 ID 的项目将被分到一起。通过这两个函数,我们得到了完整的 k-means
函数:
(defn k-means [data k]
(loop [centroids (s/sample data :size k)
previous-cluster-ids nil]
(let [cluster-id (fn [x]
(let [distance #(s/euclidean-distance x %)
distances (map distance centroids)]
(->> (apply min distances)
(index-of distances))))
cluster-ids (map cluster-id data)]
(if (not= cluster-ids previous-cluster-ids)
(recur (map centroid (clusters cluster-ids data))
cluster-ids)
clusters))))
我们首先通过抽样输入数据随机选择 k
个聚类中心点。接着,使用循环/递归不断更新聚类中心点,直到 previous-cluster-ids
和 cluster-ids
相同。此时,所有文档都没有移动到其他聚类,因此聚类过程已收敛。
对路透社文档进行聚类
现在让我们使用 k-means
函数对路透社文档进行聚类。我们先让算法简单一些,选取一些较大的文档样本。较大的文档更容易让算法识别它们之间的相似性。我们将最低字符数设定为 500 字符。这意味着我们的输入文档至少有一个标题和几句正文内容。
(defn ex-6-11 []
(let [documents (fs/glob "data/reuters-text/*.txt")
doc-count 100
k 5
tokenized (->> (map slurp documents)
(remove too-short?)
(take doc-count)
(map stem-reuters))]
(add-documents-to-dictionary! dictionary tokenized)
(-> (map #(tf-vector dictionary %) tokenized)
(k-means k))))
我们使用fs
库(github.com/Raynes/fs
)通过调用fs/glob
,并使用匹配所有文本文件的模式,来创建文件列表进行聚类。我们删除那些太短的文件,标记前 100 个,并将它们添加到字典中。我们为输入创建tf
向量,然后对它们调用k-means
。
如果你运行前面的示例,你将得到一组聚类文档向量,但这些并不太有用。让我们创建一个summary
函数,利用字典报告每个聚类中最常见的术语。
(defn cluster-summary [dict clusters top-term-count]
(for [cluster clusters]
(let [sum-terms (if (= (i/nrow cluster) 1)
cluster
(->> (i/trans cluster)
(map i/sum)
(i/trans)))
popular-term-ids (->> (map-indexed vector sum-terms)
(sort-by second >)
(take top-term-count)
(map first))
top-terms (map #(id->term dict %) popular-term-ids)]
(println "N:" (i/nrow cluster))
(println "Terms:" top-terms))))
(defn ex-6-12 []
(cluster-summary dictionary (ex-6-11) 5))
k-均值算法本质上是一个随机算法,对质心的初始位置敏感。我得到了以下输出,但你的结果几乎肯定会不同:
;; N: 2
;; Terms: (rocket launch delta satellit first off weather space)
;; N: 4
;; Terms: (said will for system 000 bank debt from bond farm)
;; N: 12
;; Terms: (said reuter for iranian it iraq had new on major)
;; N: 62
;; Terms: (said pct dlr for year mln from reuter with will)
;; N: 20
;; Terms: (said for year it with but dlr mln bank week)
不幸的是,我们似乎没有得到很好的结果。第一个聚类包含两篇关于火箭和太空的文章,第三个聚类似乎由关于伊朗的文章组成。大多数文章中最常见的词汇是“said”。
使用 TF-IDF 进行更好的聚类
词汇 频率-逆文档频率(TF-IDF)是一种通用方法,用于在文档向量中加权术语,以便在整个数据集中流行的术语不会像那些较不常见的术语那样被高估。这捕捉了直观的信念——也是我们之前观察到的——即“said”这样的词汇并不是构建聚类的强有力基础。
Zipf 定律
Zipf 定律指出,任何词汇的频率与其在频率表中的排名成反比。因此,最频繁的词汇出现的频率大约是第二常见词汇的两倍,第三常见词汇的三倍,依此类推。让我们看看这一规律是否适用于我们的路透社语料库:
(defn ex-6-13 []
(let [documents (fs/glob "data/reuters-text/*.txt")
doc-count 1000
top-terms 25
term-frequencies (->> (map slurp documents)
(remove too-short?)
(take doc-count)
(mapcat tokenize-reuters)
(frequencies)
(vals)
(sort >)
(take top-terms))]
(-> (c/xy-plot (range (inc top-terms)) term-frequencies
:x-label "Terms"
:y-label "Term Frequency")
(i/view))))
使用前述代码,我们可以计算出前 1,000 篇路透社文档中前 25 个最流行词汇的频率图。
在前 1,000 篇文档中,最流行的词汇出现了将* 10,000 次。第 25^(th)个最流行的词汇总共出现了大约 1,000 次。实际上,数据显示,词汇在路透社语料库中的出现频率比频率表中的位置所暗示的要高。这很可能是由于路透社语料库的公告性质,导致相同的短词被反复使用。
计算 TF-IDF 权重
计算 TF-IDF 只需要对我们已经创建的代码进行两个修改。首先,我们必须追踪给定术语出现在哪些文档中。其次,在构建文档向量时,我们必须适当地加权该术语。
既然我们已经创建了一个术语字典,我们不妨将每个术语的文档频率存储在其中。
(defn inc-df! [dictionary term-id]
(doto dictionary
(swap! update-in [:df term-id] (fnil inc 0))))
(defn build-df-dictionary! [dictionary document]
(let [terms (distinct document)
dict (build-dictionary! dictionary document)
term-ids (map #(term-id dictionary %) document)]
(doseq [term-id term-ids]
(inc-df! dictionary term-id))
dict))
build-df-dictionary
函数之前接受一个字典和一个术语序列。我们从不同的术语中构建字典,并查找每个术语的 term-id
。最后,我们遍历术语 ID 并为每个 ID 增加 :df
。
如果一个文档包含词语 w[1]、…、w[n],那么词语 w[i] 的逆文档频率定义为:
即,词语出现在文档中的倒数。如果一个词语在一组文档中频繁出现,它的 DF 值较大,而 IDF 值较小。在文档数量很大的情况下,通常通过乘以一个常数(通常是文档总数 N)来对 IDF 值进行归一化,因此 IDF 公式看起来像这样:
词语 w[i] 的 TF-IDF 权重 W[i] 由词频和逆文档频率的乘积给出:
然而,前述公式中的 IDF 值仍然不理想,因为对于大型语料库,IDF 项的范围通常远大于 TF,并且可能会压倒 TF 的效果。为了减少这个问题并*衡 TF 和 IDF 项的权重,通常的做法是使用 IDF 值的对数:
因此,词语 w[i] 的 TF-IDF 权重 w[i] 变为:
这是经典的 TF-IDF 权重:常见词语的权重较小,而不常见的词语的权重较大。确定文档主题的重要词语通常具有较高的 TF 和适中的 IDF,因此二者的乘积成为一个较大的值,从而在结果向量中赋予这些词语更多的权重。
(defn document-frequencies [dict terms]
(->> (map (partial term-id dict) terms)
(select-keys (:df @dict))))
(defn tfidf-vector [dict doc-count terms]
(let [tf (term-frequencies dict terms)
df (document-frequencies dict (distinct terms))
idf (fn [df] (i/log (/ doc-count df)))
tfidf (fn [tf df] (* tf (idf df)))]
(map->vector dict (merge-with tfidf tf df))))
前述代码计算了先前定义的 term-frequencies
和从字典中提取的 document-frequencies
的 TF-IDF 值。
使用 TF-IDF 的 k-means 聚类
在进行前述调整后,我们可以计算路透社文档的 TF-IDF 向量。以下示例是基于新的 tfidf-vector
函数修改的 ex-6-12
:
(defn ex-6-14 []
(let [documents (fs/glob "data/reuters-text/*.txt")
doc-count 100
k 5
tokenized (->> (map slurp documents)
(remove too-short?)
(take doc-count)
(map stem-reuters))]
(reduce build-df-dictionary! dictionary tokenized)
(-> (map #(tfidf-vector dictionary doc-count %) tokenized)
(k-means k)
(cluster-summary dictionary 10))))
前述代码与之前的示例非常相似,但我们已经替换了新的 build-df-dictionary
和 tfidf-vector
函数。如果你运行该示例,应该会看到比之前稍微改进的输出:
N: 5
Terms: (unquot unadjust year-on-year novemb grew sundai labour m-3 ahead 120)
N: 15
Terms: (rumor venezuela azpurua pai fca keat ongpin boren gdp moder)
N: 16
Terms: (regan drug lng soviet bureau deleg gao dean fdic algerian)
N: 46
Terms: (form complet huski nrc rocket north underwrit card oat circuit)
N: 18
Terms: (freez cocoa dec brown bean sept seixa telex argentin brown-forman)
尽管由于词语已被词干化,顶级词语可能难以解释,但这些词语代表了每个聚类中最为不寻常的常见词。注意,“said”不再是所有聚类中评分最高的词语。
更好的 n-gram 聚类
从前面列出的词汇表来看,应该清楚地意识到,通过将文档简化为无序的词汇序列,我们丧失了多少信息。如果没有句子的上下文,我们很难准确把握每个聚类的含义。
然而,向量空间模型本身并没有什么内在的限制,阻止我们保持输入词语的顺序。我们可以简单地创建一个新的术语来表示多个词的组合。这个组合词,可能表示多个连续的输入词,被称为 n-gram。
一个 n-gram 的例子可能是“new york”(纽约)或“stock market”(股市)。事实上,因为它们包含两个词,所以这些被称为 bigrams(二元组)。n-grams 可以是任意长度的。n-gram 越长,携带的上下文信息越多,但它的出现也就越为罕见。
n-grams 与 瓦片法(shingling)的概念密切相关。当我们对 n-grams 进行瓦片化处理时,我们是在创建重叠的词组序列。瓦片化(shingling)一词来源于这些词语像屋顶瓦片一样重叠的方式。
(defn n-grams [n words]
(->> (partition n 1 words)
(map (partial str/join " "))))
(defn ex-6-15 []
(let [terms (reuters-terms "reut2-020.sgm-761.txt")]
(n-grams 2 terms)))
;; ("reagan says" "says he" "he sees" "sees no" "no recession")
目前,使用 2-grams 就能让我们(例如)区分数据集中“coconut”一词的不同用法:“coconut oil”(椰子油)、“coconut planters”(椰子种植者)、“coconut plantations”(椰子种植园)、“coconut farmers”(椰子农民)、“coconut association”(椰子协会)、“coconut authority”(椰子管理机构)、“coconut products”(椰子产品)、“coconut exports”(椰子出口)、“coconut industry”(椰子产业)和相当吸引人的“coconut chief”(椰子首领)。这些词对定义了不同的概念——有时是微妙的不同——我们可以在不同的文档中捕捉并进行比较。
我们可以通过结合不同长度的 n-gram 的结果,来实现 n-gram 和瓦片化的双重优势:
(defn multi-grams [n words]
(->> (range 1 (inc n))
(mapcat #(n-grams % words))))
(defn ex-6-16 []
(let [terms (reuters-terms "reut2-020.sgm-761.txt")]
(multi-grams 4 terms)))
;; ("reagan" "says" "he" "sees" "no" "recession" "reagan says"
;; "says he" "he sees" "sees no" "no recession" "reagan says he"
;; "says he sees" "he sees no" "sees no recession" "reagan says he
;; sees" "says he sees no" "he sees no recession")
虽然词干提取(stemming)和停用词移除(stop word removal)起到了缩减字典规模的作用,而使用 TF-IDF 则提高了每个词在文档中的权重效用,但生成 n-grams 的效果是大大增加了我们需要处理的词汇数量。
特征的爆炸性增长将立刻使我们在 Incanter 中实现 k-means 的效果超出负荷。幸运的是,有一个叫做 Mahout 的机器学习库,专门设计用于在海量数据上运行像 k-means 这样的算法。
使用 Mahout 进行大规模聚类
Mahout (mahout.apache.org/
) 是一个机器学习库,旨在分布式计算环境中使用。该库的 0.9 版本支持 Hadoop,并且是我们将在此使用的版本。
注意
在本文撰写时,Mahout 0.10 刚刚发布,并且同样支持 Spark。Spark 是一个替代性的分布式计算框架,我们将在下一章介绍。
我们在上一章看到,Hadoop 的一个抽象概念是序列文件:Java 键和值的二进制表示。许多 Mahout 的算法期望在序列文件上操作,我们需要创建一个序列文件作为 Mahout 的 k-means 算法的输入。Mahout 的 k-means 算法也期望将其输入作为向量,表示为 Mahout 的向量类型之一。
尽管 Mahout 包含提取向量的类和实用程序,我们将借此机会演示如何将 Parkour 和 Mahout 一起使用。这样不仅能让我们对创建的向量有更细粒度的控制,还可以展示更多 Parkour 在指定 Hadoop 作业时的能力。
将文本文件转换为序列文件
然而,我们不会定义自定义作业来将文本文档转换为序列文件表示:Mahout 已经定义了一个有用的 SequenceFilesFromDirectory
类,用来转换文本文件目录。我们将使用这个工具来创建一个代表整个 reuters-txt
目录内容的单一文件。
尽管序列文件可能物理上分布在不同的块中(例如在 HDFS 上),但它在逻辑上是一个文件,表示所有输入文档作为键/值对。键是文件名,值是文件的文本内容。
以下代码将处理转换:
(:import [org.apache.mahout.text
SequenceFilesFromDirectory])
(defn text->sequencefile [in-path out-path]
(SequenceFilesFromDirectory/main
(into-array String (vector "-i" in-path
"-o" out-path
"-xm" "sequential"
"-ow"))))
(defn ex-6-17 []
(text->sequencefile "data/reuters-text"
"data/reuters-sequencefile"))
SequenceFilesFromDirectory
是 Mahout 的一个实用类,是一套可以在命令行调用的类之一。
提示
由于运行前面的示例是后续示例的先决条件,它也可以在命令行上运行:
lein create-sequencefile
我们直接调用 main
函数,传递我们通常在命令行上传递的参数,作为字符串数组。
使用 Parkour 创建 Mahout 向量
现在我们已经有了 Reuters 语料库的序列文件表示,我们需要将每个文档(现在表示为一个单一的键/值对)转换为向量。我们之前已经看到如何使用共享字典(被建模为 Clojure 原子)来实现这一点。原子确保即使在多线程环境中,每个不同的术语也会得到自己的 ID。
我们将使用 Parkour 和 Hadoop 来生成向量,但这带来了一个挑战。由于 MapReduce 编程的特点是映射器并行工作且不共享状态,我们如何为每个单词分配一个唯一的 ID 呢?Hadoop 并未提供类似于 Clojure 原子的机制来在集群中的节点之间共享可变状态,实际上,最小化共享状态是扩展分布式应用程序的关键。
因此,创建一个共享的唯一 ID 集合对我们的 Parkour 作业来说是一个有趣的挑战:让我们看看如何以分布式方式为我们的字典生成唯一的 ID。
创建分布式唯一 ID
在我们查看 Hadoop 特定的解决方案之前,值得注意的是,创建一个集群范围内唯一标识符的一个简单方法是创建一个通用唯一标识符,或 UUID。
(defn uuid []
(str (java.util.UUID/randomUUID)))
这会创建一个形式为 3a65c7db-6f41-4087-a2ec-8fe763b5f185
的长字节字符串,这几乎可以保证不会与世界上任何地方生成的其他 UUID 冲突。
虽然这对于生成唯一 ID 有效,但可能的 ID 数量是天文数字,而 Mahout 的稀疏向量表示需要以整数的形式初始化向量的基数。使用 uuid
生成的 ID 太大了。此外,这并不能帮助我们协调 ID 的创建:集群中的每台机器都会生成不同的 UUID 来表示相同的术语。
解决这个问题的一种方法是使用术语本身来生成唯一 ID。如果我们使用一致性哈希函数从每个输入术语创建一个整数,集群中的所有机器都会生成相同的 ID。由于良好的哈希函数可能会为唯一的输入术语产生唯一的输出,这个技巧可能会很好地工作。虽然会有一些哈希冲突(即两个词哈希到相同的 ID),但这应该只是整体中的一个小比例。
注意
哈希特征本身以创建唯一 ID 的方法通常被称为“哈希技巧”。尽管它通常用于文本向量化,但它可以应用于任何涉及大量特征的问题。
然而,生成跨整个集群唯一的区分 ID 的挑战,给了我们一个讨论 Hadoop 中 Parkour 所揭示的一个有用功能的机会:分布式缓存。
使用 Hadoop 分布式唯一 ID
如果我们要计算唯一的集群范围 ID,考虑一下我们的 Parkour 映射器和化简器可能是什么样子。映射器很简单:我们希望计算每个遇到的术语的文档频率,因此以下映射器简单地为每个唯一术语返回一个向量:向量的第一个元素(键)是术语本身,第二个元素(值)是 1
。
(defn document-count-m
{::mr/source-as :vals}
[documents]
(->> documents
(r/mapcat (comp distinct stemmer/stems))
(r/map #(vector % 1))))
化简器的任务是将这些术语的键/值对(术语与文档计数)进行归约,以确保每个唯一术语都有一个唯一的 ID。做这件事的一个简单方法是确保集群上只有一个化简器。由于所有术语都会传递给这个单一的进程,化简器可以简单地保持一个内部计数器,并像我们之前用 Clojure 原子做的那样为每个术语分配一个 ID。然而,这并没有利用 Hadoop 的分布式能力。
我们尚未介绍 Parkour 的一个特点,即每个映射器(mapper)和归约器(reducer)内部可以访问的运行时上下文。Parkour 将 parkour.mapreduce/*context*
动态变量绑定到执行我们映射器和归约器的 Hadoop 任务的任务上下文中。任务上下文包含以下属性(其中之一):
属性 | 类型 | 描述 |
---|---|---|
mapred.job.id |
字符串 | 作业的 ID |
mapred.task.id |
int | 任务尝试的 ID |
mapred.task.partition |
int | 任务在作业中的 ID |
其中最后一个属性 mapred.task.partition
,是 Hadoop 分配的任务编号,保证是一个单调递增的唯一整数,且在整个集群中唯一。这个数字就是我们任务的全局偏移量。在每个任务内部,我们还可以保持一个本地偏移量,并在处理每个单词时输出全局和本地偏移量。全局偏移量和本地偏移量一起为集群中的每个术语提供了一个唯一的标识符。
以下图表展示了在三个独立的映射器上处理的八个术语的过程:
每个映射器只知道它自己的分区号和术语的本地偏移量。然而,这两个数字就是计算唯一全局 ID 所需的全部信息。前面提到的 计算偏移量 盒子确定了每个任务分区的全局偏移量是什么。分区 1 的全局偏移量为 0。分区 2 的全局偏移量为 3,因为分区 1 处理了 3 个单词。分区 3 的偏移量为 5,因为分区 1 和 2 处理了总共 5 个单词,依此类推。
要使上述方法工作,我们需要知道三件事:映射器的全局偏移量、术语的本地偏移量,以及每个映射器处理的术语总数。这三组数字可以用来为每个术语定义一个全局唯一的集群 ID。生成这三组数字的归约器定义如下。它引入了一些新概念,稍后我们将详细讨论。
(defn unique-index-r
{::mr/source-as :keyvalgroups,
::mr/sink-as dux/named-keyvals}
[coll]
(let [global-offset (conf/get-long mr/*context*
"mapred.task.partition" -1)]
(tr/mapcat-state
(fn [local-offset [word doc-counts]]
[(inc local-offset)
(if (identical? ::finished word)
[[:counts [global-offset local-offset]]]
[[:data [word [[global-offset local-offset]
(apply + doc-counts)]]]])])
0 (r/mapcat identity [coll [[::finished nil]]]))))
归约器执行的第一步是获取 global-offset
,即此归约器对应的任务分区。我们使用 mapcat-state
,这是在 transduce 库中定义的一个函数(github.com/brandonbloom/transduce
),来构建一系列元组,格式为 [[:data ["apple" [1 4]] [:data ["orange" [1 5]] ...]
,其中数字向量 [1 4]
分别表示全局和本地偏移量。最后,当我们到达此归约任务的末尾时,我们会将一个元组以 [:counts [1 5]]
的格式添加到序列中。这代表了该特定归约器分区 1
的最终本地计数 5
。因此,单个归约器计算了我们计算所有术语 ID 所需的三项元素。
提供给::mr/source-as
的关键字是我们之前没有遇到过的。在上一章中,我们看到了如何通过:keyvals
、:keys
和:vals
等塑形选项,让 Parkour 知道我们希望如何提供数据,以及我们将返回的数据结构。对于聚合器,Parkour 描述了一个更全面的塑形函数集,考虑到输入可能是分组的。下图展示了可用的选项:
提供给::mr/sink-as
的选项我们也没有遇到过。parkour.io.dux
命名空间提供了输出去多路复用的选项。实际上,这意味着通过将 sink 指定为dux/named-keyvals
,单个聚合器可以写入多个不同的输出。换句话说,我们在数据管道中引入了一个分支:部分数据写入一个分支,其余数据写入另一个分支。
设置了dux/named-keyvals
的 sink 规范后,我们元组的第一个元素将被解释为写入的目标;元组的第二个元素将被视为要写入的键值对。因此,我们可以将:data
(本地和全局偏移量)写入一个目标,将:counts
(每个映射器处理的术语数量)写入另一个目标。
接下来展示的是使用我们定义的映射器和聚合器的作业。与上一章中指定的 Parkour 作业类似,我们将输入、映射、分区、归约和输出步骤串联起来。
(defn df-j [dseq]
(-> (pg/input dseq)
(pg/map #'document-count-m)
(pg/partition (mra/shuffle [:string :long]))
(pg/reduce #'unique-index-r)
(pg/output :data (mra/dsink [:string index-value])
:counts (mra/dsink [:long :long]))))
前面的代码和我们之前看到的作业规范之间有两个主要区别。首先,我们的输出指定了两个命名的 sink:每个聚合器的输出各一个。其次,我们使用parkour.io.avro
命名空间作为mra
来为我们的数据指定模式,使用(mra/dsink [:string long-pair])
。
在上一章中,我们使用了 Tesser 的FressianWritable
将任意 Clojure 数据结构序列化到磁盘。这是因为FressianWritable
的内容不需要 Hadoop 解析:该值是完全不透明的。使用 Parkour 时,我们可以定义自定义的键/值对类型。由于 Hadoop 需要将键和值作为独立实体进行解析(用于读取、分区和写入序列文件),Parkour 允许我们使用parkour.io.avro
命名空间定义“元组模式”,该模式明确地定义了键和值的类型。long-pair
是一个自定义模式,用于将本地和全局偏移量存储在单个元组中。
(def long-pair (avro/tuple-schema [:long :long]))
由于模式是可组合的,我们可以在定义输出模式时引用long-pair
模式:(mra/dsink [:string long-pair])
。
注意
Parkour 使用Acbracad
库来通过 Avro 序列化 Clojure 数据结构。有关序列化选项的更多信息,请参考 Abracad 文档,网址:github.com/damballa/abracad
。
让我们来看看 Parkour 暴露的 Hadoop 的另一个功能,它使得我们的术语 ID 任务比其他方式更高效:分布式缓存。
使用分布式缓存共享数据
如我们在上一节中讨论的那样,如果我们知道每个映射器中每个单词的本地偏移量,并且我们知道每个映射器处理了多少记录,那么我们就能计算出每个单词的唯一连续 ID。
几页前展示的图示包含了两个中央框,每个框上标有计算偏移量和全局 ID。这些框直接对应于我们接下来要展示的函数:
(defn global-id [offsets [global-offset local-offset]]
(+ local-offset (get offsets global-offset)))
(defn calculate-offsets [dseq]
(->> (into [] dseq)
(sort-by first)
(reductions (fn [[_ t] [i n]]
[(inc i) (+ t n)])
[0 0])
(into {})))
一旦我们计算出了用于生成唯一 ID 的偏移量映射,我们希望这些映射能够作为共享资源对所有的映射任务和归约任务可用。既然我们已经以分布式的方式生成了偏移量,我们也希望以分布式的方式进行使用。
分布式缓存是 Hadoop 让任务能够访问公共数据的一种方式。这比通过可能昂贵的数据连接共享少量数据(足够小可以存储在内存中的数据)要高效得多。
在从分布式缓存读取数据之前,我们需要向它写入一些数据。这可以通过 Parkour 的parkour.io.dval
命名空间来实现:
(defn unique-word-ids [conf df-data df-counts]
(let [offsets-dval (-> (calculate-offsets df-counts)
(dval/edn-dval))]
(-> (pg/input df-data)
(pg/map #'word-id-m offsets-dval)
(pg/output (mra/dsink [word-id]))
(pg/fexecute conf `word-id)
(->> (r/map parse-idf)
(into {}))
(dval/edn-dval))))
在这里,我们使用dval/edn-dval
函数将两组数据写入分布式缓存。第一组数据是刚刚定义的calculate-offsets
函数的结果,它将传递给word-id-m
映射器使用。写入分布式缓存的第二组数据是它们的输出。我们将看到如何在word-id-m
函数中生成这些数据,如下所示:
(defn word-id-m
{::mr/sink-as :keys}
[offsets-dval coll]
(let [offsets @offsets-dval]
(r/map
(fn [[word word-offset]]
[word (global-id offsets word-offset)])
coll)))
dval/edn-dval
返回的值实现了IDRef
接口。这意味着我们可以使用 Clojure 的deref
函数(或者@
反引用宏)来获取它所包装的值,就像我们操作 Clojure 的原子值一样。第一次对分布式值进行反引用时,会从分布式缓存中下载数据到本地映射器缓存中。一旦数据在本地可用,Parkour 会负责重新构建我们以 EDN 格式写入的数据结构(偏移量的映射)。
从输入文档构建 Mahout 向量
在前面的章节中,我们绕了一些路介绍了几个新的 Parkour 和 Hadoop 概念,但现在我们终于可以为 Mahout 构建文本向量,并为每个术语使用唯一的 ID。为了简洁起见,部分代码被省略,但整个任务可以在cljds.ch6.vectorizer
示例代码命名空间中查看。
如前所述,Mahout 的k-means 实现要求我们使用其向量类之一提供输入的向量表示。由于我们的字典很大,并且大多数文档只使用其中少数术语,因此我们将使用稀疏向量表示。以下代码利用了一个dictionary
分布式值,为每个输入文档创建一个org.apache.mahout.math.RandomAccessSparseVector
:
(defn create-sparse-tfidf-vector [dictionary [id doc]]
(let [vector (RandomAccessSparseVector. (count dictionary))]
(doseq [[term tf] (-> doc stemmer/stems frequencies)]
(let [term-info (get dictionary term)
id (:id term-info)
idf (:idf term-info)]
(.setQuick vector id (* tf idf))))
[id vector]))
(defn create-tfidf-vectors-m [dictionary coll]
(let [dictionary @dictionary]
(r/map #(create-sparse-tfidf-vector dictionary %) coll)))
最后,我们利用create-tfidf-vectors-m
函数,它将我们所学的内容汇聚成一个单一的 Hadoop 作业:
(defn tfidf [conf dseq dictionary-path vector-path]
(let [doc-count (->> dseq (into []) count)
[df-data df-counts] (pg/execute (df-j dseq) conf df)
dictionary-dval (make-dictionary conf df-data
df-counts doc-count)]
(write-dictionary dictionary-path dictionary-dval)
(-> (pg/input dseq)
(pg/map #'create-tfidf-vectors-m dictionary-dval)
(pg/output (seqf/dsink [Text VectorWritable] vector-path))
(pg/fexecute conf `vectorize))))
这个任务处理字典的创建,将字典写入分布式缓存,然后使用我们刚定义的映射器,将每个输入文档转换为 Mahout 向量。为了确保与 Mahout 的序列文件兼容,我们将最终输出的键/值类设置为Text
和VectorWritable
,其中键是文档的原始文件名,值是文档内容的 Mahout 向量表示。
我们可以通过运行以下命令来调用此作业:
(defn ex-6-18 []
(let [input-path "data/reuters-sequencefile"
output-path "data/reuters-vectors"]
(vectorizer/tfidf-job (conf/ig) input-path output-path)))
该作业将字典写入dictionary-path
(我们稍后还需要它),并将向量写入vector-path
。
提示
由于运行前面的例子是后续示例的先决条件,它也可以通过命令行访问:
lein create-vectors
接下来,我们将学习如何使用这些向量来实际执行 Mahout 的聚类。
使用 Mahout 运行 k-means 聚类
现在,我们已经有了一个适合 Mahout 使用的向量序列文件,接下来就该在整个数据集上实际运行k-means 聚类了。与我们本地的 Incanter 版本不同,Mahout 在处理完整的 Reuters 语料库时不会遇到任何问题。
与SequenceFilesFromDirectory
类一样,我们已经为 Mahout 的另一个命令行程序KMeansDriver
创建了一个封装器。Clojure 变量名使得我们更容易理解每个命令行参数的作用。
(defn run-kmeans [in-path clusters-path out-path k]
(let [distance-measure "org.apache.mahout.common.distance.CosineDistanceMeasure"
max-iterations 100
convergence-delta 0.001]
(KMeansDriver/main
(->> (vector "-i" in-path
"-c" clusters-path
"-o" out-path
"-dm" distance-measure
"-x" max-iterations
"-k" k
"-cd" convergence-delta
"-ow"
"-cl")
(map str)
(into-array String)))))
我们提供字符串org.apache.mahout.common.distance.CosineDistanceMeasure
,以指示驱动程序我们希望使用 Mahout 的余弦距离度量实现。Mahout 还包括EuclideanDistanceMeasure
和TanimotoDistanceMeasure
(类似于 Jaccard 距离,是 Jaccard 指数的补集,但将作用于向量而非集合)。还有几种其他的距离度量可以选择;请参考 Mahout 文档以了解所有可用选项。
有了前面的run-kmeans
函数后,我们只需要告诉 Mahout 在哪里访问我们的文件。与上一章一样,我们假设 Hadoop 在本地模式下运行,所有文件路径都相对于项目根目录:
(defn ex-6-19 []
(run-kmeans "data/reuters-vectors/vectors"
"data/kmeans-clusters/clusters"
"data/kmeans-clusters"
10))
这个例子可能会运行一段时间,因为 Mahout 需要对我们的大数据集进行迭代。
查看 k-means 聚类结果
完成后,我们希望能看到每个聚类的聚类总结,就像我们在 Incanter 实现中所做的那样。幸运的是,Mahout 定义了一个ClusterDumper
类,正是用来做这个事情的。我们需要提供聚类的位置,当然,还需要提供字典的位置。提供字典意味着输出将返回每个聚类的顶部术语。
(defn run-cluster-dump [in-path dict-path points-dir out-path]
(let [distance-measure
"org.apache.mahout.common.distance.CosineDistanceMeasure"]
(ClusterDumper/main
(->> (vector "-i" in-path
"-o" out-path
"-d" dict-path
"--pointsDir" points-dir
"-dm" distance-measure
"-dt" "sequencefile"
"-b" "100"
"-n" "20"
"-sp" "0"
"--evaluate")
(map str)
(into-array String)))))
接下来,我们定义实际调用run-cluster-dump
函数的代码:
(defn path-for [path]
(-> (fs/glob path)
(first)
(.getAbsolutePath)))
(defn ex-6-20 []
(run-cluster-dump
(path-for "data/kmeans-clusters/clusters-*-final")
"data/reuters-vectors/dictionary/part-r-00000"
"data/kmeans-clusters/clusteredPoints"
"data/kmeans-clusterdump"))
我们再次使用me.raynes.fs
库来确定最终聚类所在的目录。Mahout 会在包含最终聚类的目录名后附加-final
,但我们事先并不知道哪个目录会是这个最终目录。fs/glob
函数会查找与clusters-*-final
模式匹配的目录,并将*
替换为实际目录名称中包含的迭代编号。
解释聚类输出
如果你在任何文本编辑器中打开之前示例创建的文件data/kmeans-clusterdump
,你将看到表示 Mahout 聚类的顶部术语的输出。该文件可能很大,但下面提供了一个摘录:
:VL-11417{n=312 c=0.01:0.039, 0.02:0.030, 0.07:0.047, 0.1:0.037, 0.10:0.078, 0.11:0.152, 0.12:0.069,
Top Terms:
tonnes => 2.357810452962533
department => 1.873890568048526
wheat => 1.7797807546762319
87 => 1.6685682321206117
u.s => 1.634764205186795
mln => 1.5050923755535712
agriculture => 1.4595903158187866
ccc => 1.4314624499051998
usda => 1.4069041441648433
dlrs => 1.2770121846443567
第一行包含关于聚类的信息:ID(在本例中为VL-11417
),后面跟着包含聚类大小和聚类质心位置的大括号。由于文本已转换为权重和数字 ID,单独解读质心是不可行的。不过,质心描述下方的顶部术语暗示了聚类的内容;它们是聚类汇聚的核心术语。
VL-12535{n=514 c=[0:0.292, 0.25:0.015, 0.5:0.012, 00:0.137, 00.46:0.018, 00.50:0.036, 00.91:0.018, 0
Top Terms:
president => 3.330068911559851
reagan => 2.485271333256584
chief => 2.1148699971952327
senate => 1.876725117983985
officer => 1.8531712558019022
executive => 1.7373591731030653
bill => 1.6326750159727461
chairman => 1.6280977206471365
said => 1.6279512813119108
house => 1.5771017798189988
前面提到的两个聚类暗示了数据集中存在的两个明确主题,尽管由于k-means 算法的随机性,你的聚类可能会有所不同。
根据初始质心和算法运行的迭代次数,你可能会看到某些聚类在某些方面看起来“更好”或“更差”。这将基于对聚类词汇如何组合在一起的直觉反应。但通常,仅通过查看顶部术语并不能清楚地判断聚类的效果如何。无论如何,直觉并不是判断无监督学习算法质量的可靠方法。我们理想的情况是有一个定量指标来衡量聚类的效果。
聚类评估指标
在我们上一节查看的文件底部,你会看到一些统计信息,表明数据的聚类效果如何:
Inter-Cluster Density: 0.6135607681542804
Intra-Cluster Density: 0.6957348405534836
这两个数字可以看作是我们在[第二章,推理和第三章,相关性中看到的组内方差和组间方差的等价物。理想情况下,我们希望聚类内的方差较低(或密度较高),而聚类间的密度较低。
聚类间密度
聚类间密度是聚类质心之间的*均距离。好的聚类通常不会有太靠*的质心。如果它们太*,那就意味着聚类正在创建具有相似特征的组,并可能在区分聚类成员方面存在难以支持的情况。
因此,理想情况下,我们的聚类应产生具有大聚类间距离的聚类。
聚类内部密度
相反,聚类内密度是衡量聚类紧凑性的指标。理想情况下,聚类会识别出彼此相似的项目组。紧凑的聚类表示聚类中的所有项目彼此高度相似。
因此,最佳的聚类结果会产生紧凑、独特的聚类,具有高聚类内密度和低聚类间密度。
然而,数据到底应该有多少个聚类并不总是清楚。考虑以下示例,它展示了同一数据集以不同聚类数分组的情况。很难有足够的信心判断理想的聚类数是多少。
尽管前面的示意图是人为构造的,但它展示了聚类数据时的一个普遍问题。通常没有一个明确的“最佳”聚类数。最有效的聚类将很大程度上取决于数据的最终用途。
然而,我们可以通过确定某些质量评分如何随着聚类数量的变化来推断可能的较优 k 值。质量评分可以是像聚类间密度或聚类内密度这样的统计数据。当聚类数接*理想值时,我们期望该质量评分的值会提高。相反,当聚类数偏离理想值时,我们期望质量评分会下降。因此,为了合理地估算数据集中多少个聚类是合理的,我们应该为不同的 k 值多次运行算法。
使用 Parkour 计算均方根误差
最常见的聚类质量度量之一是*方误差和(SSE)。对于每个点,误差是测量到最*聚类质心的距离。因此,总的聚类 SSE 是聚类点到其相应质心的所有聚类的和:
其中 µ[i] 是聚类 S[i] 中点的质心,k 是聚类的总数,n 是点的总数。
因此,在 Clojure 中计算 RMSE 时,我们需要能够将聚类中的每个点与其对应的聚类质心关联起来。Mahout 将聚类质心和聚类点保存在两个不同的文件中,因此在下一节中我们将把它们合并。
加载聚类点和质心
给定一个父目录(例如 data/reuters-kmeans/kmeans-10
),以下函数将使用 Parkour 的 seqf/dseq
函数从序列文件加载键/值对,并将点加载到按聚类 ID 索引的向量映射中。在这种情况下,键是聚类 ID(整数),值是 TF-IDF 向量。
(defn load-cluster-points [dir]
(->> (points-path dir)
(seqf/dseq)
(r/reduce
(fn [accum [k v]]
(update-in accum [k] conj v)) {})))
上述函数的输出是一个按聚类 ID 索引的映射,其值是聚类点的序列。同样,以下函数将把每个聚类转换为一个按聚类 ID 索引的映射,其值是包含 :id
和 :centroid
键的映射。
(defn load-cluster-centroids [dir]
(let [to-tuple (fn [^Cluster kluster]
(let [id (.getId kluster)]
[id {:id id
:centroid (.getCenter kluster)}]))]
(->> (centroids-path dir)
(seqf/dseq)
(r/map (comp to-tuple last))
(into {}))))
拥有两个按聚类 ID 索引的映射意味着将聚类的点与聚类中心结合起来,只需对映射调用 merge-with
并提供一个自定义的合并函数即可。在以下代码中,我们将聚类的点合并到包含聚类 :id
和 :centroid
的映射中。
(defn assoc-points [cluster points]
(assoc cluster :points points))
(defn load-clusters [dir]
(->> (load-cluster-points dir)
(merge-with assoc-points
(load-cluster-centroids dir))
(vals)))
最终输出是一个单一的映射,按聚类 ID 索引,其中每个值都是一个包含 :id
、:centroid
和 :points
的映射。我们将在下一节中使用这个映射来计算聚类的 RMSE。
计算聚类 RMSE
为了计算 RMSE,我们需要能够确定每个点与其关联的聚类中心之间的距离。由于我们使用了 Mahout 的 CosineDistanceMeasure
来执行初始聚类,因此我们也应该使用余弦距离来评估聚类。事实上,我们可以直接利用 Mahout 的实现。
(def measure
(CosineDistanceMeasure.))
(defn distance [^DistanceMeasure measure a b]
(.distance measure a b))
(defn centroid-distances [cluster]
(let [centroid (:centroid cluster)]
(->> (:points cluster)
(map #(distance measure centroid %)))))
(defn squared-errors [cluster]
(->> (centroid-distances cluster)
(map i/sq)))
(defn root-mean-square-error [clusters]
(->> (mapcat squared-errors clusters)
(s/mean)
(i/sqrt)))
如果将 RMSE 与聚类数绘制成图,你会发现随着聚类数的增加,RMSE 会逐渐下降。单一聚类将具有最高的 RMSE 错误(原始数据集与均值的方差),而最低的 RMSE 将是每个点都在自己的聚类中的退化情况(RMSE 为零)。显然,这两个极端都无法很好地解释数据的结构。然而,RMSE 不是线性下降的。当聚类数从 1 增加时,它会急剧下降,但一旦超过“自然”聚类数后,下降的速度就会变慢。
因此,判断理想聚类数的一种方法是绘制 RMSE 随聚类数变化的图表。这就是所谓的 肘部法。
使用肘部法确定最佳的 k 值
为了使用肘部法确定 k 的值,我们需要多次重新运行 k-均值聚类。以下代码实现了对 2
到 21
之间的所有 k 值进行聚类。
(defn ex-6-21 []
(doseq [k (range 2 21)
:let [dir (str "data/kmeans-clusters-" k)]]
(println dir)
(run-kmeans "data/reuters-vectors/vectors"
(str dir "/clusters")
dir k)))
这可能需要一些时间,所以不妨去泡杯热饮:println
语句会记录每次聚类的运行情况,让你知道进展如何。在我的笔记本电脑上,整个过程大约需要 15 分钟。
完成后,你应该能够运行示例,生成每个聚类值的 RMSE 散点图:
(defn ex-6-22 []
(let [ks (range 2 21)
ys (for [k ks
:let [dir (str "data/kmeans-clusters-" k)
clusters (load-clusters dir)]]
(root-mean-square-error clusters))]
(-> (c/scatter-plot ks ys
:x-label "k"
:y-label "RMSE")
(i/view))))
这应该返回一个类似于以下的图表:
上面的散点图展示了 RMSE 与簇数之间的关系。应该能看出,当k超过大约 13 个簇时,RMSE 的变化速率放慢,进一步增加簇数会带来递减的收益。因此,上图表明对于我们的路透社数据,约 13 个簇是一个不错的选择。
肘部法则提供了一种直观的方式来确定理想的簇数,但在实践中有时难以应用。这是因为我们必须解释每个k对应的 RMSE 曲线的形状。如果k很小,或者 RMSE 包含较多噪声,可能不容易看出肘部在哪里,甚至是否存在肘部。
注意
由于聚类是一种无监督学习算法,我们在此假设簇的内部结构是验证聚类质量的唯一手段。如果已知真实的簇标签,则可以使用外部验证度量(例如熵)来验证模型的成功,这在第四章,分类中我们曾遇到过。
其他聚类评估方案旨在提供更清晰的方式来确定精确的簇数。我们将讨论的两种方法是邓恩指数和戴维斯-鲍尔丁指数。它们都是内部评估方案,意味着它们只关注聚类数据的结构。每种方法都旨在以不同的方式识别产生最紧凑、最分离的簇的聚类。
使用邓恩指数确定最优 k
邓恩指数提供了另一种选择最优k的方式。邓恩指数不考虑聚类数据中的*均误差,而是考虑两种“最坏情况”的比率:两个簇中心之间的最小距离,除以最大簇直径。因此,较高的邓恩指数表示更好的聚类,因为通常我们希望簇间距离较大,而簇内距离较小。
对于k个簇,我们可以通过以下方式表达邓恩指数:
其中,δ(C[i],C[j])是两个簇C[i]和C[j]之间的距离,而表示最大簇的大小(或散布)。
计算一个簇的散布有几种可能的方法。我们可以计算簇内最远两个点之间的距离,或者计算簇内所有数据点之间成对距离的*均值,或者计算每个数据点到簇中心的距离的*均值。在以下代码中,我们通过计算从簇中心到各点的中位距离来确定大小。
(defn cluster-size [cluster]
(-> cluster
centroid-distances
s/median))
(defn dunn-index [clusters]
(let [min-separation (->> (combinations clusters 2)
(map #(apply separation %))
(apply min))
max-cluster-size (->> (map cluster-size clusters)
(apply max))]
(/ min-separation max-cluster-size)))
上面的代码使用了 clojure.math.combinatorics
中的 combinations
函数(github.com/clojure/math.combinatorics/
)来生成所有聚类的懒序列对。
(defn ex-6-23 []
(let [ks (range 2 21)
ys (for [k ks
:let [dir (str "data/kmeans-clusters-" k)
clusters (load-clusters dir)]]
(dunn-index clusters))]
(-> (c/scatter-plot ks ys
:x-label "k"
:y-label "Dunn Index")
(i/view))))
我们在上面的代码中使用了 dunn-index
函数来为 k=2 到 k=20 的聚类生成散点图:
更高的 Dunn 指数表示更好的聚类效果。因此,最佳聚类似乎是 k=2,接下来是 k=6,然后是 k=12 和 k=13,它们紧随其后。我们来尝试一种替代的聚类评估方案,并比较结果。
使用 Davies-Bouldin 指数确定最优的 k 值
Davies-Bouldin 指数是一种替代的评估方案,用于衡量聚类中所有值的大小和分离度的*均比率。对于每个聚类,找到一个替代聚类,使得聚类大小之和与聚类间距离的比率最大化。Davies-Bouldin 指数被定义为所有聚类的此值的*均值:
其中 δ(C[i],C[j]) 是两个聚类中心 C[i] 和 C[j]* 之间的距离,S[i] 和 S[j]* 是散布度。我们可以使用以下代码计算 Davies-Bouldin 指数:
(defn scatter [cluster]
(-> (centroid-distances cluster)
(s/mean)))
(defn assoc-scatter [cluster]
(assoc cluster :scatter (scatter cluster)))
(defn separation [a b]
(distance measure (:centroid a) (:centroid b)))
(defn davies-bouldin-ratio [a b]
(/ (+ (:scatter a)
(:scatter b))
(separation a b)))
(defn max-davies-bouldin-ratio [[cluster & clusters]]
(->> (map #(davies-bouldin-ratio cluster %) clusters)
(apply max)))
(defn rotations [xs]
(take (count xs)
(partition (count xs) 1 (cycle xs))))
(defn davies-bouldin-index [clusters]
(let [ds (->> (map assoc-scatter clusters)
(rotations)
(map max-davies-bouldin-ratio))]
(s/mean ds)))
现在,让我们在散点图中绘制 k=2 到 k=20 的 Davies-Bouldin 指数:
(defn ex-6-24 []
(let [ks (range 2 21)
ys (for [k ks
:let [dir (str "data/kmeans-clusters-" k)
clusters (load-clusters dir)]]
(davies-bouldin-index clusters))]
(-> (c/scatter-plot ks ys
:x-label "k"
:y-label "Davies-Bouldin Index")
(i/view))))
这将生成以下图表:
与 Dunn 指数不同,Davies-Bouldin 指数对于良好的聚类方案是最小化的,因为一般来说,我们寻找的是紧凑的聚类,并且聚类间的距离较大。前面的图表表明,k=2 是理想的聚类大小,其次是 k=13。
k-均值算法的缺点
k-均值算法是最流行的聚类算法之一,因为它相对易于实现,并且可以很好地扩展到非常大的数据集。尽管它很受欢迎,但也有一些缺点。
k-均值算法是随机的,并不能保证找到全局最优的聚类解。事实上,该算法对离群点和噪声数据非常敏感:最终聚类的质量可能高度依赖于初始聚类中心的位置。换句话说,k-均值算法通常会发现局部最优解,而非全局最优解。
上图说明了如何根据不良的初始聚类质心,k均值可能会收敛到局部最小值。如果初始聚类质心的位置合适,非最优聚类仍然可能发生,因为 k 均值倾向于选择具有相似大小和密度的聚类。在聚类大小和密度不大致相等时,k 均值可能无法收敛到最自然的聚类:
此外,k 均值算法强烈倾向于选择“球形”的聚类。形状较为复杂的聚类往往不能被 k 均值算法很好地识别。
在下一章,我们将看到各种降维技术如何帮助解决这些问题。但在那之前,我们先来培养一种直觉,理解另一种定义距离的方式:作为衡量某个元素距离一“组”物体的远*。
马哈拉诺比斯距离度量
在本章开始时,我们通过展示 Jaccard、欧几里得和余弦距离如何与数据表示相关,看到了一些距离度量在特定数据下可能比其他度量更合适。选择距离度量和聚类算法时需要考虑的另一个因素是数据的内部结构。请考虑以下散点图:
很“显然”,箭头所指的点与其他点不同。我们可以清楚地看到它远离其他点的分布,因此代表了一个异常点。然而,如果我们计算所有点到均值(“质心”)的欧几里得距离,这个点将被其他距离相等或更远的点所掩盖:
(defn ex-6-25 []
(let [data (dataset-with-outlier)
centroid (i/matrix [[0 0]])
distances (map #(s/euclidean-distance centroid %) data)]
(-> (c/bar-chart (range 202) distances
:x-label "Points"
:y-label "Euclidean Distance")
(i/view))))
上述代码生成了以下图表:
马哈拉诺比斯距离在计算距离时考虑了变量之间的协方差。在二维空间中,我们可以将欧几里得距离想象成一个从质心发出的圆:圆上所有的点与质心的距离相等。马哈拉诺比斯距离将这个圆拉伸并扭曲,以纠正不同变量的尺度差异,并考虑它们之间的相关性。我们可以在以下示例中看到这种效果:
(defn ex-6-26 []
(let [data (dataset-with-outlier)
distances (map first (s/mahalanobis-distance data))]
(-> (c/bar-chart (range 202) distances
:x-label "Points"
:y-label "Mahalanobis Distance")
(i/view))))
上述代码使用了 incanter.stats
提供的函数来绘制相同数据点的马哈拉诺比斯距离。结果显示在下图中:
该图清晰地标识出一个点与其他点相比,距离要远得多。这与我们对这个点应该被认为比其他点更远的看法一致。
维度灾难
然而,马哈拉诺比斯距离度量无法克服一个事实,这就是所谓的维度灾难。随着数据集中的维度数量增加,每个点都趋向于和其他点一样远。我们可以通过下面的代码简单地演示这一点:
(defn ex-6-27 []
(let [distances (for [d (range 2 100)
:let [data (->> (dataset-of-dimension d)
(s/mahalanobis-distance)
(map first))]]
[(apply min data) (apply max data)])]
(-> (c/xy-plot (range 2 101) (map first distances)
:x-label "Number of Dimensions"
:y-label "Distance Between Points"
:series-label "Minimum Distance"
:legend true)
(c/add-lines (range 2 101) (map second distances)
:series-label "Maximum Distance")
(i/view))))
上面的代码找出了在一个合成生成的 100 个点的数据集中,任意两个点对之间的最小距离和最大距离。随着维度数接*数据集中的元素数量,我们可以看到每对元素之间的最小距离和最大距离逐渐接*:
这一效果非常显著:随着维度数量的增加,最接*的两个点之间的距离也在增加。最远的两个点之间的距离也增加,但增长速度较慢。最后,当维度为 100,数据点为 100 时,每个点似乎与其他每个点的距离都相等。
当然,这是合成的随机生成数据。如果我们尝试对数据进行聚类,我们隐含地希望数据中会有一个可识别的内部结构,我们可以将其提取出来。然而,随着维度数量的增加,这种结构会变得越来越难以识别。
总结
在本章中,我们学习了聚类的过程,并介绍了流行的k-均值聚类算法,用于聚类大量的文本文件。
这为我们提供了一个机会,探讨文本处理所带来的具体挑战,其中数据通常是杂乱的、模糊的,并且是高维的。我们看到停用词和词干提取如何帮助减少维度的数量,以及 TF-IDF 如何帮助识别最重要的维度。我们还看到如何通过n-gram 和 shingling 技术提取每个词的上下文,代价是大量的术语扩展。
我们已经更详细地探讨了 Parkour,并且看到了它如何用来编写复杂的、可扩展的 Hadoop 作业。特别是,我们看到了如何利用分布式缓存和自定义元组模式来编写处理 Clojure 数据结构表示的数据的 Hadoop 作业。我们用这两者实现了生成唯一的、跨集群的术语 ID 的方法。
最后,我们见证了高维空间带来的挑战:所谓的“维度灾难”。在下一章,我们将更详细地探讨这个话题,并描述一系列应对技术。我们将继续探索“相似性”和“差异性”的概念,同时考虑推荐问题:我们如何将用户与物品匹配起来。
第七章 推荐系统
"喜欢这种东西的人会发现这正是他们喜欢的东西。" | ||
---|---|---|
—归功于亚伯拉罕·林肯 |
在前一章中,我们使用 k-means 算法对文本数据进行了聚类。这要求我们必须有一种衡量文本文件相似度的方式。在本章中,我们将研究推荐系统,并利用这种相似度的概念来推荐我们认为用户可能喜欢的物品。
我们还看到了高维数据所带来的挑战——即所谓的维度灾难。尽管这不是推荐系统特有的问题,但本章将展示多种技术来应对其影响。特别地,我们将通过主成分分析和奇异值分解来确定最重要的维度,并通过布隆过滤器和 MinHash 来使用概率方法压缩非常高维的数据集。此外——由于确定物品之间的相似度涉及大量的成对比较——我们将学习如何使用局部敏感哈希高效地预计算最可能的相似性分组。
最后,我们将介绍 Spark,一个分布式计算框架,以及一个相关的 Clojure 库,名为 Sparkling。我们将展示如何结合 Spark 的机器学习库 MLlib 使用 Sparkling 构建分布式推荐系统。
但首先,我们将从讨论推荐系统的基本类型开始,并在 Clojure 中实现其中一个最简单的推荐系统。接下来,我们将演示如何使用前一章中介绍的 Mahout 来创建多种不同类型的推荐系统。
下载代码和数据
在本章中,我们将使用来自movielens.org/
的电影推荐数据。该网站由 GroupLens 运营,GroupLens 是明尼苏达大学双城校区计算机科学与工程系的一个研究实验室。
数据集已通过grouplens.org/datasets/movielens/
提供了不同大小的版本。在本章中,我们将使用"MovieLens 100k"——一个包含来自 1,000 名用户对 1,700 部电影的 100,000 条评分的集合。由于数据发布于 1998 年,它开始显得有些陈旧,但它提供了一个适中的数据集,供我们展示推荐系统的原理。本章将为你提供处理最新发布的"MovieLens 20M"数据所需的工具:1.38 万用户对 27,000 部电影的 2,000 万条评分。
注意
本章的代码可以从 Packt Publishing 网站或github.com/clojuredatascience/ch7-recommender-systems
获取。
像往常一样,提供了一个 shell 脚本,它将下载并解压数据到本章的 data
目录。你可以在相同的代码目录中运行它:
script/download-data.sh
在你运行脚本或手动下载解压数据后,你应该能看到以字母 "u" 开头的各种文件。在本章中,我们主要使用的评分数据位于 ua.base
文件中。ua.base
、ua.test
、ub.base
和 ub.test
文件包含用于交叉验证的数据子集。我们还将使用 u.item
文件,它包含关于电影本身的信息。
检查数据
评分文件是制表符分隔的,包含用户 ID、物品 ID、评分和时间戳字段。用户 ID 与 u.user
文件中的一行数据相对应,该文件提供了用户的基本人口统计信息,如年龄、性别和职业:
(defn ex-7-1 []
(->> (io/resource "ua.base")
(io/reader)
(line-seq)
(first)))
;; "1\t1\t5\t874965758"
字符串显示的是文件中的一行——一行以制表符分隔,包含用户 ID、物品 ID、评分(1-5)以及显示评分时间的时间戳。评分是一个从 1 到 5 的整数,时间戳表示自 1970 年 1 月 1 日以来的秒数。物品 ID 与 u.item
文件中的一行数据相对应。
我们还需要加载 u.item
文件,这样我们就可以确定正在评分的物品(以及返回预测的物品)的名称。以下示例展示了 u.item
文件中数据的存储方式:
(defn ex-7-2 []
(->> (io/resource "u.item")
(io/reader)
(line-seq)
(first)))
;; "1|Toy Story (1995)|01-Jan-1995||http://us.imdb.com/M/title-exact?Toy%20Story%20(1995)|0|0|0|1|1|1|0|0|0|0|0|0|0|0|0|0|0|0|0"
前两个字段分别是物品 ID 和名称。之后的字段,在本章中没有使用,分别是发行日期、电影在 IMDB 上的 URL,以及一系列标志,指示电影的类型。
解析数据
由于数据将全部加载到主内存中以便于操作,我们将定义几个函数,将评分数据加载到 Clojure 数据结构中。line->rating
函数接收一行数据,将其按制表符分割成各个字段,将每个字段转换为 long
数据类型,然后使用 zipmap
将序列转换为一个包含提供键的映射:
(defn to-long [s]
(Long/parseLong s))
(defn line->rating [line]
(->> (s/split line #"\t")
(map to-long)
(zipmap [:user :item :rating])))
(defn load-ratings [file]
(with-open [rdr (io/reader (io/resource file))]
(->> (line-seq rdr)
(map line->rating)
(into []))))
(defn ex-7-3 []
(->> (load-ratings "ua.base")
(first)))
;; {:rating 5, :item 1, :user 1}
让我们写一个函数来解析 u.items
文件,这样我们就能知道电影名称是什么:
(defn line->item-tuple [line]
(let [[id name] (s/split line #"\|")]
(vector (to-long id) name)))
(defn load-items [path]
(with-open [rdr (io/reader (io/resource path))]
(->> (line-seq rdr)
(map line->item-tuple)
(into {}))))
load-items
函数返回一个从物品 ID 到电影名称的映射,以便我们可以根据物品 ID 查找电影名称。
(defn ex-7-4 []
(-> (load-items "u.item")
(get 1)))
;; "Toy Story (1995)"
在这些简单的函数就绪后,是时候了解不同类型的推荐系统了。
推荐系统的类型
推荐问题通常有两种处理方法。这两种方法都利用了我们在上一章中遇到的事物相似度的概念。
一种方法是从我们知道用户喜欢的项目开始,并推荐其他具有相似属性的项目。例如,如果用户对动作冒险电影感兴趣,我们可能会给他们展示我们能够提供的所有动作冒险电影的列表。或者,如果我们拥有比单纯的电影类型更多的数据——例如标签列表——那么我们就可以推荐具有最多共同标签的电影。这个方法叫做基于内容的过滤,因为我们利用项目本身的属性来生成相似项目的推荐。
另一种推荐方法是将用户的偏好作为输入。这可以是电影的数字评分,或者是购买过或曾观看过的电影。我们一旦获得这些数据,就可以识别出其他有相似评分(或购买历史、观看习惯等)的用户偏好的电影,而这些电影是该用户尚未明确表示偏好的。这种方法考虑了其他用户的行为,因此通常被称为协同过滤。协同过滤是一种强大的推荐手段,因为它利用了所谓的“群体智慧”。
在本章中,我们将主要研究协同过滤方法。然而,通过利用相似度的概念,我们也会为你提供实施基于内容的推荐所需的概念。
协同过滤
通过仅考虑用户与项目的关系,这些技术不需要了解项目本身的属性。这使得协同过滤成为一种非常通用的技术——相关的项目可以是任何可以被评分的东西。我们可以将协同过滤视为尝试填充一个稀疏矩阵,其中包含用户的已知评分。我们希望能够用预测的评分来替代未知值,并推荐评分最高的预测项。
请注意,每个问号位于行和列的交叉处。行包含特定用户对他们评分的所有电影的偏好。列包含所有评分过某部电影的用户的评分。要仅使用该矩阵中的其他数字来替代该矩阵中的问号,是协同过滤的核心挑战。
基于项目和基于用户的推荐
在协同过滤领域内,我们可以有区分地讲两种过滤类型——基于项目的推荐和基于用户的推荐。对于基于项目的推荐,我们从用户已经高度评分的一组项目出发,寻找其他相似的项目。这个过程可以通过下图来展示:
基于图表中提供的信息,推荐器可能会推荐项目 B,因为它与两部已被高度评价的项目相似。
我们可以将这种方法与下面图示的基于用户的推荐过程进行对比。基于用户的推荐旨在识别与目标用户口味相似的其他用户,以推荐他们高评分的项目,但这些项目目标用户尚未评分。
基于用户的推荐器很可能会推荐项目 B,因为它已经被两位口味相似的用户高评分过。我们将在本章实现两种推荐器。让我们从最简单的方法之一——Slope One 项目推荐预测器开始。
Slope One 推荐器
Slope One 推荐器是由 Daniel Lemire 和 Anna Maclachlan 在 2005 年的论文中提出的一系列算法的一部分。在本章中,我们将介绍加权 Slope One 推荐器。
注意
你可以在 lemire.me/fr/abstracts/SDM2005.html
阅读介绍 Slope One 推荐器的论文。
为了说明加权 Slope One 推荐的工作原理,让我们考虑一个简单的例子,四个用户,分别标记为 W、X、Y 和 Z,他们对三部电影——《阿马迪乌斯》、《勇敢的心》和《卡萨布兰卡》进行了评分。每个用户提供的评分在下图中有所示:
和任何推荐问题一样,我们的目标是用某种估算来替代问号,估算用户对电影的评分:最高预测评分可以用来向用户推荐新电影。
加权 Slope One 是一个分两步进行的算法。首先,我们必须计算每对项目之间的评分差异。其次,我们将使用这些差异集合来进行预测。
计算项目差异
Slope One 算法的第一步是计算每对项目之间的*均评分差异。以下公式看起来可能有点吓人,但实际上它很简单:
公式计算了,这是项目 i 和 j 评分之间的*均差异。它通过对所有从 S[i],[j](R) 中提取的 u 求和来实现,S[i],[j](R) 是所有对两个项目都打过分的用户集合。求和的量是 u[i] - u[j],即用户对项目 i 和 j 评分之间的差异,除以
,即集合 S[i],[j](R) 的基数,或已对两个项目都打过分的人的数量。
让我们通过将算法应用到之前图表中的评分来使这一点更加具体。让我们计算《阿马迪乌斯》和《勇敢的心》之间的评分差异。
有两个用户都对这两部电影进行了评分,因此 是 2。对于这两个用户,我们计算他们对每部电影的评分差异并将它们加起来。
结果是 2,这意味着*均来说,用户对《莫扎特传》评分比对《勇敢的心》高 2 分。如你所料,如果我们反过来计算差异,即从《勇敢的心》到《莫扎特传》,得到的结果是 -2:
我们可以把结果看作所有评分过这两部电影的用户所评定的两部电影之间的*均评分差异。如果我们再多做几次计算,最终可以得到下图中的矩阵,显示了三部电影之间的每对评分差异的*均值:
根据定义,主对角线上的值为零。我们可以通过以下 Clojure 代码来表达计算,而不是继续手动计算,这段代码将计算出每个用户评价过的项目对之间的差异序列:
(defn conj-item-difference [dict [i j]]
(let [difference (- (:rating j) (:rating i))]
(update-in dict [(:item i) (:item j)] conj difference)))
(defn collect-item-differences [dict items]
(reduce conj-item-difference dict
(for [i items
j items
:when (not= i j)]
[i j])))
(defn item-differences [user-ratings]
(reduce collect-item-differences {} user-ratings))
以下示例使用本章开头定义的函数将 ua.base
文件加载为评分序列。collect-item-differences
函数接收每个用户的评分列表,并计算每对评分项目之间的差异。item-differences
函数会对所有用户进行汇总,构建出所有评分过这两项的用户之间的评分差异序列:
(defn ex-7-5 []
(->> (load-ratings "ua.base")
(group-by :user)
(vals)
(item-differences)
(first)))
;; [893 {558 (-2 4), 453 (-1), 637 (-1), 343 (-2 -2 3 2) ...]
我们将这两个方向的列表存储为嵌套映射中的值,因此我们可以使用 get-in
获取任何两个项目之间的差异:
(defn ex-7-6 []
(let [diffs (->> (load-ratings "ua.base")
(group-by :user)
(vals)
(item-differences))]
(println "893:343" (get-in diffs [893 343]))
(println "343:893" (get-in diffs [343 893]))))
;; 893:343 (-2 -2 3 2)
;; 343:893 (2 2 -3 -2)
为了使用这些差异进行预测,我们需要将它们汇总为均值,并记录基于该均值的评分次数:
(defn summarize-item-differences [related-items]
(let [f (fn [differences]
{:mean (s/mean differences)
:count (count differences)})]
(map-vals f related-items)))
(defn slope-one-recommender [ratings]
(->> (item-differences ratings)
(map-vals summarize-item-differences)))
(defn ex-7-7 []
(let [recommender (->> (load-ratings "ua.base")
(group-by :user)
(vals)
(slope-one-recommender))]
(get-in recommender [893 343])))
;; {:mean 0.25, :count 4}
该方法的一个实际好处是,我们只需执行之前的步骤一次。从这时起,我们可以通过调整均值差异和评分次数,来结合用户未来的评分,仅对用户已评分的项目进行更新。例如,如果某个用户已经评分了 10 个项目并被包含在早期的数据结构中,那么第 11 个评分只需要重新计算这 11 个项目的差异。而不必重新执行计算量较大的差异化过程来纳入新信息。
制定推荐
现在我们已经计算出了每对项目的*均差异,接下来我们可以利用这些差异向用户推荐新项目。为了了解如何操作,我们回到之前的一个例子。
用户X已经为阿马迪乌斯和勇敢的心提供了评分。我们希望推测他们会如何评分电影卡萨布兰卡,以便决定是否推荐给他们。
为了对用户进行预测,我们需要两样东西——刚刚计算出的差异矩阵和用户自己以前的评分。给定这两样东西,我们可以使用以下公式,计算用户u对项目j的预测评分!推荐生成:
如之前所示,这个方程看起来比实际要复杂,所以我们从分子开始一步步解析。
这个表达式意味着我们正在对用户u评分过的所有i项进行求和(显然不包括j,即我们试图预测评分的项目)。我们计算的总和是用户对i和j的评分差值,再加上用户u对i的评分。我们将该值乘以C[j],[i]——即评分过这两个项目的用户数量。
分母是所有评分过j以及用户u评分过的任何电影的用户数量之和。它是一个常数因子,用来调整分子的大小,以确保输出能够被解释为评分。
让我们通过计算用户 X 对《卡萨布兰卡》的预测评分来说明之前的公式,使用之前提供的差异表和评分:
因此,基于之前的评分,我们预测用户 X 会给《卡萨布兰卡》评分3.375。通过对所有其他用户评分的项目执行相同的过程,我们可以为用户 X 得出一组推荐项。
下面的 Clojure 代码计算了所有候选项的加权评分:
(defn candidates [recommender {:keys [rating item]}]
(->> (get recommender item)
(map (fn [[id {:keys [mean count]}]]
{:item id
:rating (+ rating mean)
:count count}))))
(defn weighted-rating [[id candidates]]
(let [ratings-count (reduce + (map :count candidates))
sum-rating (map #(* (:rating %) (:count %)) candidates)
weighted-rating (/ (reduce + sum-rating) ratings-count)]
{:item id
:rating weighted-rating
:count ratings-count}))
接下来,我们计算加权评分,即每个候选项的加权*均评分。加权*均确保由大量用户生成的差异比仅由少量用户生成的差异更为重要:
(defn slope-one-recommend [recommender rated top-n]
(let [already-rated (set (map :item rated))
already-rated? (fn [{:keys [id]}]
(contains? already-rated id))
recommendations (->> (mapcat #(candidates recommender %)
rated)
(group-by :item)
(map weighted-rating)
(remove already-rated?)
(sort-by :rating >))]
(take top-n recommendations)))
最后,我们从候选池中移除任何已经评分过的项目,并按评分降序排列剩余的项目:我们可以选择最高评分的结果,将其作为我们的顶级推荐。以下示例计算了用户 ID 为 1 的用户的最高评分:
(defn ex-7-8 []
(let [user-ratings (->> (load-ratings "ua.base")
(group-by :user)
(vals))
user-1 (first user-ratings)
recommender (->> (rest user-ratings)
(slope-one-recommender))
items (load-items "u.item")
item-name (fn [item]
(get items (:item item)))]
(->> (slope-one-recommend recommender user-1 10)
(map item-name))))
之前的示例会花一些时间来构建 Slope One 推荐器并输出差异。它需要几分钟,但完成后,您应该能看到如下内容:
;; ("Someone Else's America (1995)" "Aiqing wansui (1994)"
;; "Great Day in Harlem, A (1994)" "Pather Panchali (1955)"
;; "Boys, Les (1997)" "Saint of Fort Washington, The (1993)"
;; "Marlene Dietrich: Shadow and Light (1996) " "Anna (1996)"
;; "Star Kid (1997)" "Santa with Muscles (1996)")
尝试在 REPL 中运行slope-one-recommender
,并为多个用户预测推荐。你会发现,一旦差异矩阵构建完成,生成推荐的速度非常快。
用户和物品推荐系统的实际考虑
正如我们在前一节中看到的,编译所有物品的成对差异是一个耗时的工作。基于物品的推荐系统的一个优点是,物品之间的成对差异通常会随着时间的推移保持相对稳定。因此,差异矩阵只需定期计算。正如我们所看到的,也可以非常容易地增量更新;对于已经评价过 10 个物品的用户,如果他们再评价一个新物品,我们只需要调整这 11 个物品的差异,而不需要每次更新矩阵时都从头计算差异。
基于物品的推荐系统的运行时随着它们存储的物品数量增加而扩展。然而,在用户数量相比物品数量较少的情况下,实施基于用户的推荐系统可能更加高效。例如,对于内容聚合类网站,物品的数量可能远远超过用户数量,这类网站非常适合使用基于用户的推荐系统。
在上一章中,我们接触到的Mahout
库包含了创建各种推荐系统的工具,包括基于用户的推荐系统。接下来我们将介绍这些工具。
使用 Mahout 构建基于用户的推荐系统
Mahout 库包含许多内置类,这些类设计用于协同工作,以帮助构建定制的推荐引擎。Mahout 用于构建推荐系统的功能位于org.apache.mahout.cf.taste
命名空间中。
注意
Mahout 的推荐引擎能力来自于 Taste 开源项目,该项目与 Mahout 于 2008 年合并。
在上一章中,我们学习了如何利用 Mahout 和 Clojure 的 Java 互操作性进行聚类。在本章中,我们将使用 Mahout 的推荐系统,具体使用GenericUserBasedRecommender
,它位于org.apache.mahout.cf.taste.impl.recommender
包中。
与许多基于用户的推荐系统一样,我们也需要定义一个相似度度量来量化两个用户之间的相似度。我们还需要定义一个用户邻域,作为每个用户的 10 个最相似用户的集合。
首先,我们必须加载数据。Mahout 包含一个工具类FileDataModel
,用于加载 MovieLens 数据,它位于org.apache.mahout.cf.taste.impl.model.file
包中,我们接下来将使用这个类。
(defn load-model [path]
(-> (io/resource path)
(io/file)
(FileDataModel.)))
加载数据后,我们可以使用以下代码生成推荐:
(defn ex-7-9 []
(let [model (load-model "ua.base")
similarity (EuclideanDistanceSimilarity. model)
neighborhood (NearestNUserNeighborhood. 10 similarity
model)
recommender (GenericUserBasedRecommender. model
neighborhood
similarity)
items (load-items "u.item")
item-name (fn [id] (get items id))]
(->> (.recommend recommender 1 5)
(map #(item-name (.getItemID %))))))
;; ("Big Lebowski, The (1998)" "Peacemaker, The (1997)"
;; "Rainmaker, The (1997)" "Game, The (1997)"
;; "Cool Hand Luke (1967)")
我们在前面的示例中使用的距离度量是欧氏距离。这将每个用户放置在一个由他们评价过的电影评分定义的高维空间中。
早期的图表根据用户 X,Y 和 Z 对电影 A 和 B 的评分将它们放置在二维图表上。我们可以看到用户 Y 和 Z 在这两部电影上更相似,而不是与用户 X 更相似。
如果我们试图为用户 Y 生成推荐,我们可能会推断用户 X 给出高评分的其他项目可能是不错的候选项。
k 最*邻
我们的 Mahout 基于用户的推荐器通过查看最相似用户的邻域进行推荐。这通常被称为 k-最*邻 或 k-NN。
看起来用户邻域与前一章中遇到的 k-均值聚类很相似,但实际上并非如此。这是因为每个用户位于其自己邻域的中心。在聚类中,我们的目标是建立少量的分组,但是在 k-NN 中,有多少用户就有多少邻域;每个用户都是其自己邻域的中心。
注意
Mahout 还定义了 ThresholdUserNeighbourhood
,我们可以用它来构建一个只包含彼此之间相似度在一定阈值内的用户邻域。
k-NN 算法意味着我们仅基于最相似用户的口味生成推荐。这是直觉上的合理选择;与您自己口味最相似的用户最有可能提供有意义的推荐。
自然会出现两个问题——最佳邻域大小是多少?我们应该使用哪种相似度度量?为了回答这些问题,我们可以借助 Mahout 的推荐器评估能力,查看我们的推荐器在各种不同配置下针对我们的数据的表现。
使用 Mahout 进行推荐器评估
Mahout 提供了一组类来帮助评估我们的推荐器。就像我们在第四章中使用 clj-ml
库进行交叉验证时的分类一样,Mahout 的评估通过将我们的评分分为两组:测试集和训练集来进行。
通过在训练集上训练我们的推荐器,然后评估其在测试集上的性能,我们可以了解我们的算法在真实数据上表现如何。为了处理在 Mahout 评估器提供的训练数据上训练模型的任务,我们必须提供一个符合 RecommenderBuilder
接口的对象。该接口只定义了一个方法:buildRecommender
。我们可以使用 reify
创建一个匿名的 RecommenderBuilder
类型:
(defn recommender-builder [sim n]
(reify RecommenderBuilder
(buildRecommender [this model]
(let [nhood (NearestNUserNeighborhood. n sim model)]
(GenericUserBasedRecommender. model nhood sim)))))
Mahout 在 org.apache.mahout.cf.taste.impl.eval
命名空间中提供了多种评估器。在下面的代码中,我们使用 RMSRecommenderEvaluator
类构建一个均方根误差评估器,通过传入我们加载的推荐器构建器和数据模型:
(defn evaluate-rmse [builder model]
(-> (RMSRecommenderEvaluator.)
(.evaluate builder nil model 0.7 1.0)))
我们在前面的代码中传递给 evaluate
的 nil
值表示我们没有提供自定义模型构建器,这意味着 evaluate
函数将使用基于我们提供的模型的默认模型构建器。数字 0.7
和 1.0
分别表示用于训练的数据比例和用于评估的测试数据比例。在之前的代码中,我们使用了 70% 的数据进行训练,并对剩余的 100% 数据进行评估。均方根误差(RMSE)评估器将计算预测评分与实际评分之间的均方误差的*方根。
我们可以使用前面提到的两个函数,通过欧几里得距离和 10 的邻域来评估基于用户的推荐系统的性能,方法如下:
(defn ex-7-10 []
(let [model (load-model "ua.base")
builder (recommender-builder 10
(EuclideanDistanceSimilarity. model))]
(evaluate-rmse builder model)))
;; 0.352
当然,您的结果可能会有所不同,因为评估是基于数据的随机子集进行的。
在上一章中,我们定义了欧几里得距离 d 为一个正值,其中零表示完全相似。这可以通过以下方式转换为相似度度量 s:
不幸的是,之前的度量方法会对那些有更多共同评分项目的用户产生偏差,因为每个维度都会提供进一步分开的机会。为了解决这个问题,Mahout 计算欧几里得相似度如下:
这里,n 是维度的数量。由于该公式可能导致相似度超过 1,Mahout 会将相似度裁剪为 1。
评估距离度量
在上一章中,我们遇到了各种不同的距离和相似度度量;特别地,我们使用了 Jaccard、欧几里得和余弦距离。Mahout 在 org.apache.mahout.cf.taste.impl.similarity
包中提供了这些度量的实现,分别为 TanimotoCoefficientSimilarity
、EuclideanDistanceSimilarity
和 UncenteredCosineSimilarity
。
我们刚刚评估了基于欧几里得相似度的推荐系统在评分数据上的表现,那么让我们看看其他的评估结果如何。在此过程中,我们还可以尝试 Mahout 提供的另外两种相似度度量——PearsonCorrelationSimilarity
和 SpearmanCorrelationSimilarity
。
Pearson 相关性相似度
Pearson 相关性相似度是一种基于用户品味相关性的相似度度量。下图展示了两个用户对三部电影 A、B 和 C 的评分。
欧几里得距离的一个潜在缺点是,它没有考虑到一种情况,即两个用户在电影的相对评分上完全一致,但其中一个用户可能评分更为慷慨。考虑之前例子中的两位用户。它们在电影A、B和C上的评分完全相关,但用户Y给这些电影的评分高于用户X。这两位用户之间的欧几里得距离可以通过以下公式计算:
然而,从某种意义上来说,它们是完全一致的。在第三章中,我们计算了两组数据的皮尔逊相关系数,如下所示:
在这里, 和
。前面的例子得出的皮尔逊相关系数为 1。
让我们尝试使用皮尔逊相关相似度进行预测。Mahout 通过PearsonCorrelationSimilarity
类实现了皮尔逊相关:
(defn ex-7-11 []
(let [model (load-model "ua.base")
builder (recommender-builder
10 (PearsonCorrelationSimilarity. model))]
(evaluate-rmse builder model)))
;; 0.796
事实上,使用皮尔逊相关计算电影数据时,RMSE(均方根误差)已经增加。
皮尔逊相关相似度在数学上等同于针对已经中心化的数据(均值为零的数据)计算的余弦相似度。在之前所举的两位用户X和Y的例子中,它们的均值并不相同,因此余弦相似度的结果将与皮尔逊相关相似度不同。Mahout 实现了余弦相似度作为UncenteredCosineSimilarity
。
尽管皮尔逊方法直观易懂,但在推荐引擎的背景下,它存在一些缺点。它没有考虑到两个用户共同评分的项目数量。如果他们只共享一个项目,则无法计算相似度。而且,如果一个用户总是给所有项目相同的评分,那么无法计算该用户与任何其他用户之间的相关性,即使另一个用户的评分也完全相同。也许数据中评分的多样性不足,导致皮尔逊相关相似度无法很好地工作。
斯皮尔曼等级相似度
用户相似的另一种方式是,尽管排名之间没有特别紧密的相关性,但他们的排名顺序在用户之间保持一致。考虑下面的图示,显示了两位用户对五部不同电影的评分:
我们可以看到,用户评分之间的线性相关性并不完美,因为他们的评分没有落在一条直线上。这会导致一个适中的皮尔逊相关相似度,并且余弦相似度会更低。然而,他们的偏好排序是相同的。如果我们比较用户的排名列表,它们会完全一致。
斯皮尔曼等级相关系数使用此度量来计算用户之间的差异。它被定义为排好序的项目之间的皮尔逊相关系数:
这里,n是评分的数量,而是项目i的排名差异。Mahout 实现了斯皮尔曼等级相关性,通过
SpearmanCorrelationSimilarity
类,我们将在下一段代码中使用它。由于算法需要做更多的工作,因此我们只对一个较小的子集进行评估,测试数据的 10%:
(defn ex-7-12 []
(let [model (load-model "ua.base")
builder (recommender-builder
10 (SpearmanCorrelationSimilarity. model))]
(-> (RMSRecommenderEvaluator.)
(.evaluate builder nil model 0.9 0.1))))
;; 0.907
RMSE 评估得分甚至比皮尔逊相关性相似度还要高。目前,对于 MovieLens 数据,最好的相似度度量是欧几里得相似度。
确定最优邻域大小
我们在之前的比较中没有改变的一个方面是推荐所基于的用户邻域大小。我们来看一下邻域大小如何影响 RMSE:
(defn ex-7-13 []
(let [model (load-model "ua.base")
sim (EuclideanDistanceSimilarity. model)
ns (range 1 10)
stats (for [n ns]
(let [builder (recommender-builder n sim)]
(do (println n)
(evaluate-rmse builder model))))]
(-> (c/scatter-plot ns stats
:x-label "Neighborhood size"
:y-label "RMSE")
(i/view))))
之前的代码绘制了一个散点图,展示了随着邻域从 1 增加到 10,欧几里得相似度的 RMSE 变化。
可能令人惊讶的是,随着邻域的增大,预测评分的 RMSE 也上升。最准确的预测评分基于只有两个人的邻域。但是,也许这并不令人意外:对于欧几里得相似度,最相似的用户是那些与目标用户评分最一致的用户。邻域越大,我们会观察到的相同项目的评分就越多样化。
之前的 RMSE 在0.25到0.38之间。仅凭这一点,很难判断推荐系统的表现好坏。比如,评分误差为0.38,在实际应用中会不会有很大影响呢?例如,如果我们总是猜测一个比实际高(或低)0.38的评分,那么我们的推荐值与用户自己的相对价值将会完全一致。幸运的是,Mahout 提供了一个替代的评估器,返回来自信息检索领域的多种统计数据。我们接下来将研究这些数据。
信息检索统计
我们可以通过使用一个提供更多细节的评估器来更好地了解如何改进我们的推荐系统,该评估器可以展示评估器在多个不同方面的表现。GenericRecommenderIRStatsEvaluator
函数包括了几个信息检索统计数据,提供了这些细节。
在许多情况下,我们并不需要猜测用户对电影的确切评分;呈现从最好到最差的排序列表就足够了。实际上,甚至精确的顺序可能也不是特别重要。
注意
信息检索系统是指那些根据用户查询返回结果的系统。推荐系统可以被视为信息检索系统的一个子集,其中查询是与用户相关联的先前评分集。
信息检索统计(IR 统计)评估器将推荐评估处理得有点像搜索引擎评估。搜索引擎应该尽量返回用户需要的结果,同时避免返回大量不相关的信息。这些比例通过统计中的精度和召回率来量化。
精度
信息检索系统的精度是返回的相关项所占的百分比。如果正确的推荐是真正的正例,而错误的推荐是假正例,那么精度可以通过返回的真正的正例的总数来衡量:
由于我们返回的是定义数量的推荐项,例如前 10 项,我们通常会谈论精度为 10。例如,如果模型返回了 10 个推荐项,其中 8 个是用户真实的前 10 项之一,那么模型在 10 上的精度为 80%。
召回率
召回率与精度互为补充,二者通常一起引用。召回率衡量的是返回的相关推荐项占所有相关推荐项的比例:
我们可以将其视为推荐系统实际推荐的潜在好推荐项的比例。例如,如果系统仅推荐了用户前 10 项中的 5 部电影,那么我们可以说召回率在 10 上为 50%。
Mahout 的信息检索评估器
信息检索的统计数据可以将推荐问题重新构造为逐用户的搜索问题。GenericRecommenderIRStatsEvaluator
通过移除用户一些评分最高的项目(比如前五个)来评估每个用户的推荐器性能。评估器接着会查看系统实际推荐了多少个用户的真实前五个评分项目。
我们的实现方式如下:
(defn evaluate-ir [builder model]
(-> (GenericRecommenderIRStatsEvaluator.)
(.evaluate builder nil model nil 5
GenericRecommenderIRStatsEvaluator/CHOOSE_THRESHOLD
1.0)
(bean)))
(defn ex-7-14 []
(let [model (load-model "ua.base")
builder (recommender-builder
10 (EuclideanDistanceSimilarity. model))]
(evaluate-ir builder model)))
前面代码中的“at”值为5
,我们将其传递到紧接着的GenericRecommenderIRStatsEvaluator/CHOOSE_THRESHOLD
,该代码使 Mahout 计算出一个合理的相关性阈值。前面的代码返回以下输出:
;; {:recall 0.002538071065989847, :reach 1.0,
;; :precision 0.002538071065989847,
;; :normalizedDiscountedCumulativeGain 0.0019637198336778725,
;; :fallOut 0.0011874376015289575,
;; :f1Measure 0.002538071065989847,
;; :class org.apache.mahout.cf.taste.impl.eval.IRStatisticsImpl}
评估器返回一个org.apache.mahout.cf.taste.eval.IRStatistics
实例,我们可以通过 Clojure 的bean
函数将其转换为一个映射。该映射包含评估器计算的所有信息检索统计数据。它们的含义将在下一节解释。
F-measure 和调和*均数
F-measure 也叫F1 度量或*衡 F 得分,它是精度和召回率的加权调和*均数:
调和均值与更常见的算术均值有关,实际上,它是三种毕达哥拉斯均值之一。它的定义是倒数的算术均值的倒数,在涉及速率和比率的情况中特别有用。
例如,考虑一辆车以速度x行驶距离d,然后以速度y再次行驶距离d。速度是通过行驶的距离与所用时间的比值来衡量的,因此*均速度是x和y的调和均值。如果x是 60 英里/小时,而y是 40 英里/小时,则*均速度为 48 英里/小时,计算方式如下:
注意,这比算术均值要低,算术均值会是 50 英里/小时。如果* d代表的是一段时间而不是距离,比如车辆在一定时间内以速度x行驶,然后在相同时间内以速度y行驶,那么它的*均速度就是x和y*的算术均值,即 50 英里/小时。
F-度量可以推广为F[β]-度量,允许独立调整与精确度或召回率相关的权重:
常见的度量有F[2],它将召回率的权重设为精确度的两倍,和F[0.5],它将精确度的权重设为召回率的两倍。
落选率
也叫做假阳性率,指从所有非相关推荐中检索到的非相关推荐的比例:
与我们迄今看到的其他信息检索统计量不同,落选率越低,我们的推荐系统表现越好。
归一化折扣累计增益
折扣累计增益(DCG)是基于推荐实体的分级相关性来衡量推荐系统性能的指标。其值范围从零到一,值为一表示完美的排名。
折扣累计增益的前提是,高相关性结果在搜索结果列表中排名较低时,应该根据它们的相关性和在结果列表中出现的距离进行惩罚。其计算公式如下:
这里,rel[i]表示位置i处结果的相关性,p是排名中的位置。前面展示的版本是一种常见的公式,强调检索相关结果。
由于搜索结果列表的长度取决于查询,我们不能仅使用 DCG 来一致地比较结果。相反,我们可以按相关性对结果进行排序,再次计算 DCG。由于这样可以为结果(按相关性排序)提供最佳的累计折扣增益,因此该结果被称为理想折扣累计增益(IDCG)。
将 DCG 与 IDCG 的比值取出,即为归一化折扣累计增益:
在完美的排名算法中,DCG将等于IDCG,从而导致nDCG为 1.0。由于nDCG的结果范围是从零到一,它提供了一种比较不同查询引擎相对表现的方式,每个引擎返回的结果数量不同。
绘制信息检索结果
我们可以使用以下代码绘制信息检索评估结果:
(defn plot-ir [xs stats]
(-> (c/xy-plot xs (map :recall stats)
:x-label "Neighbourhood Size"
:y-label "IR Statistic"
:series-label "Recall"
:legend true)
(c/add-lines xs (map :precision stats)
:series-label "Precision")
(c/add-lines xs
(map :normalizedDiscountedCumulativeGain stats)
:series-label "NDCG")
(i/view)))
(defn ex-7-15 []
(let [model (load-model "ua.base")
sim (EuclideanDistanceSimilarity. model)
xs (range 1 10)
stats (for [n xs]
(let [builder (recommender-builder n sim)]
(do (println n)
(evaluate-ir builder model))))]
(plot-ir xs stats)))
这将生成以下图表:
在前面的图表中,我们可以看到,最高的精度对应于邻域大小为 2;咨询最相似的用户能产生最少的假阳性。然而,您可能已经注意到,报告的精度和召回率值相当低。随着邻域增大,推荐系统将有更多候选推荐可以做。但请记住,信息检索统计是以 5 为基准计算的,意味着只有前五条推荐会被计入。
在推荐系统中,这些度量方法有一个微妙的问题——精度完全依赖于我们预测用户已评分其他项目的能力。即使推荐的是用户非常喜爱的稀有项目,推荐系统仍然会因为推荐了用户未评分的稀有项目而受到惩罚。
布尔偏好的推荐
在本章中,一直假设用户对某个项目的评分是一个重要的事实。我们迄今为止所探讨的距离度量方法都试图以不同的方式预测用户未来评分的数值。
另一种距离度量方法认为,用户为某个项目指定的评分远不如他们是否对该项目进行评分重要。换句话说,所有评分,无论好坏,都可以被视为相同。考虑到每当用户对一部电影评分较低时,往往还有更多电影用户甚至懒得观看——更别提评分了。还有许多其他情况,其中布尔偏好是推荐的主要依据,例如,用户在社交媒体上的喜欢或收藏。
为了使用布尔相似度度量,我们首先需要将我们的模型转换为布尔偏好模型,下面的代码可以实现这一点:
(defn to-boolean-preferences [model]
(-> (GenericBooleanPrefDataModel/toDataMap model)
(GenericBooleanPrefDataModel.)))
(defn boolean-recommender-builder [sim n]
(reify RecommenderBuilder
(buildRecommender [this model]
(let [nhood (NearestNUserNeighborhood. n sim model)]
(GenericBooleanPrefUserBasedRecommender.
model nhood sim)))))
将用户的评分视为布尔值可以将用户的电影评分列表简化为集合表示,正如我们在上一章中看到的,Jaccard 指数可用于确定集合相似度。Mahout 实现了一种与 Jaccard 指数密切相关的相似度度量,称为Tanimoto 系数。
注意
Tanimoto 系数适用于每个索引代表一个可以是零或一的特征的向量,而 Jaccard 指数适用于可能包含或不包含某个元素的集合。使用哪种度量仅取决于你的数据表示——这两种度量是等价的。
让我们使用 Mahout 的 IR 统计评估器绘制不同邻域大小的 IR 统计图:
(defn ex-7-16 []
(let [model (to-boolean-preferences (load-model "ua.base"))
sim (TanimotoCoefficientSimilarity. model)
xs (range 1 10)
stats (for [n xs]
(let [builder
(boolean-recommender-builder n sim)]
(do (println n)
(evaluate-ir builder model))))]
(plot-ir xs stats)))
之前的代码生成了以下图表:
对于布尔推荐系统,更大的邻域可以提高精确度评分。考虑到我们在欧几里得相似度中观察到的结果,这一发现颇为引人注目。然而,值得记住的是,对于布尔偏好,没有相对项目偏好的概念,它们要么被评分,要么没有评分。最相似的用户,因此组成邻域的群体,将是那些简单地评分了相同项目的用户。这个群体越大,我们越有可能预测出用户评分的项目。
此外,由于布尔偏好没有相对评分,早期图表中缺少归一化折扣累积增益。缺乏顺序可能会使布尔偏好看起来不如其他数据那样有吸引力,但它们仍然非常有用,正如我们接下来所看到的那样。
隐式反馈与显式反馈
实际上,与其尝试从用户那里获得他们喜欢和不喜欢的明确评分,不如采用一种常见的技术——直接观察用户活动。例如,在电子商务网站上,浏览过的商品集可以提供用户感兴趣的商品种类的指示。同样,用户在网站上浏览的页面列表也是他们感兴趣的内容类型的强烈指示。
使用点击和页面浏览等隐式来源可以大大增加用于预测的信息量。它还避免了所谓的“冷启动”问题,即用户必须在你提供任何推荐之前提供明确的评分;用户只要进入你的网站,就会开始生成数据。
在这些情况下,每次页面浏览都可以被视为一个元素,代表用户偏好的一个大型页面集,并且可以使用布尔相似度度量来推荐相关内容。对于一个热门网站,这样的集合显然会很快变得非常庞大。不幸的是,Mahout 0.9 的推荐引擎设计用于在单个服务器内存上运行,因此它们对我们可以处理的数据量施加了限制。
在我们查看设计用于在集群机器上运行并随着数据量的增长而扩展的替代推荐系统之前,让我们先绕道看看执行降维的方法。我们将从概率性减少非常大数据集大小的方法开始。
大规模数据集的概率方法
大集合在数据科学的许多场景中都有出现。我们很可能会在处理用户的隐式反馈时遇到它们,如前所述,但接下来的方法可以应用于任何可以表示为集合的数据。
使用布隆过滤器测试集合成员资格
布隆过滤器是数据结构,它通过压缩集合的大小来保留我们判断某个项目是否属于该集合的能力。压缩的代价是一些不确定性。布隆过滤器告诉我们某个项目可能在集合中,但如果它不在集合中,它会告诉我们确切的答案。在需要节省磁盘空间而小幅牺牲确定性的场合,它们是集合压缩的热门选择。
布隆过滤器的基础数据结构是一个位向量——一系列可以包含 1 或 0(或 true 或 false)的单元格。压缩级别(及对应的不确定性增加)可以通过两个参数配置——k 个哈希函数和m 位。
之前的图示说明了如何对输入项(顶部方框)进行多次哈希。每个哈希函数输出一个整数,作为位向量的索引。匹配哈希索引的元素被设置为 1。下图展示了另一个元素如何被哈希到不同的位向量中,生成一组不同的索引,这些索引将被赋值为 1:
我们可以使用以下 Clojure 实现布隆过滤器。我们使用谷歌的 MurmurHash 实现,并使用不同的种子来提供k个不同的哈希函数:
(defn hash-function [m seed]
(fn [x]
(-> (Hashing/murmur3_32 seed)
(.hashUnencodedChars x)
(.asInt)
(mod m))))
(defn hash-functions [m k]
(map (partial hash-function m) (range k)))
(defn indices-fn [m k]
(let [f (apply juxt (hash-functions m k))]
(fn [x]
(f x))))
(defn bloom-filter [m k]
{:filter (vec (repeat m false))
:indices-fn (indices-fn m k)})
之前的代码将布隆过滤器定义为一个包含:filter
(位向量)和:indices
函数的映射。indices
函数负责应用k个哈希函数来生成k个索引。我们将 0 表示为false
,1 表示为true
,但效果是相同的。我们使用代码创建一个长度为8
、具有5
个哈希函数的布隆过滤器,示例如下:
(defn ex-7-17 []
(bloom-filter 8 5))
;; {:filter [false false false false false false false false],
;; :indices-fn #<Bloom_filter$indices_fn$fn__43538
;; cljds.ch7.Bloom_filter$indices_fn$fn__43538@3da200c>}
响应是一个包含两个键的映射——过滤器本身(一个布尔值向量,初始全为 false),以及由五个哈希函数生成的indices
函数。我们可以将之前的代码与一个简单的Bloom-assoc
函数结合使用:
(defn set-bit [seq index]
(assoc seq index true))
(defn set-bits [seq indices]
(reduce set-bit seq indices))
(defn bloom-assoc [{:keys [indices-fn] :as bloom} element]
(update-in bloom [:filter] set-bits (indices-fn element)))
给定一个布隆过滤器,我们只需调用indices-fn
函数来获取我们需要设置的布隆过滤器的索引:
(defn ex-7-18 []
(-> (bloom-filter 8 5)
(bloom-assoc "Indiana Jones")
(:filter)))
;; [true true false true false false false true]
要判断布隆过滤器是否包含某个项目,我们只需要查询是否所有应该为 true 的索引实际上都是 true。如果是这样,我们就可以推测该项目已被添加到过滤器中:
(defn bloom-contains? [{:keys [filter indices-fn]} element]
(->> (indices-fn element)
(map filter)
(every? true?)))
(defn ex-7-19 []
(-> (bloom-filter 8 5)
(bloom-assoc "Indiana Jones")
(bloom-contains? "Indiana Jones")))
;; true
我们将"Indiana Jones"
添加到布隆过滤器中,并发现它包含"Indiana Jones"
。现在我们改为搜索哈里森·福特的另一部电影“逃亡者”:
(defn ex-7-20 []
(-> (bloom-filter 8 5)
(bloom-assoc "Indiana Jones")
(bloom-contains? "The Fugitive")))
;; false
到目前为止,一切顺利。但我们为了这种巨大的压缩,牺牲了一些准确性。让我们搜索一个不应出现在布隆过滤器中的电影。也许是 1996 年的电影Bogus
:
(defn ex-7-21 []
(-> (bloom-filter 8 5)
(bloom-assoc "Indiana Jones")
(bloom-contains? "Bogus (1996)")))
;; true
这不是我们想要的结果。即使我们还没有将“Bogus (1996)
”添加到过滤器中,过滤器却声称它包含该项。这是布隆过滤器的折中;虽然过滤器永远不会声称某个项目没有被添加到集合中,但它可能错误地声称某个项目已经被添加,即使它没有。
注意
在我们之前章节中遇到的信息检索术语中,布隆过滤器具有 100%的召回率,但其精确度低于 100%。这种差距可以通过我们为m和k选择的值来配置。
总的来说,在 MovieLens 数据集中 1,682 个电影标题中,有 56 个电影标题被布隆过滤器错误地报告为“印第安纳·琼斯”已添加—假阳性率为 3.3%。考虑到我们只使用了五个哈希函数和一个八元素过滤器,你可能预期它的假阳性率会更高。当然,我们的布隆过滤器只包含一个元素,随着更多元素的添加,发生碰撞的概率会急剧上升。事实上,假阳性的概率大约是:
在这里,k和m分别是哈希函数的数量和过滤器的长度,就像之前一样,n是添加到集合中的项目数。对于我们之前提到的单一布隆过滤器,这给出了:
所以,实际上,理论上的假阳性率甚至比我们观察到的还要低。
布隆过滤器是一种非常通用的算法,当我们想要测试集合成员关系而又没有资源显式存储集合中的所有项时,它非常有用。由于精确度可以通过选择m和k的值进行配置,因此可以选择你愿意容忍的假阳性率。因此,它们被广泛应用于各种数据密集型系统中。
布隆过滤器的一个缺点是无法检索你已添加到过滤器中的值;虽然我们可以使用过滤器来测试集合成员关系,但如果没有进行详尽的检查,我们无法知道该集合包含什么。对于推荐系统(实际上对于其他一些系统,如聚类),我们主要关注的是两个集合之间的相似性,而不是它们的精确内容。但是在这里,布隆过滤器未能满足我们的需求;我们无法可靠地使用压缩后的过滤器来衡量两个集合之间的相似性。
接下来,我们将介绍一种算法,它可以保留通过 Jaccard 相似度衡量的集合相似性。它在保留布隆过滤器提供的可配置压缩的同时,也保留了集合的相似性。
使用 MinHash 计算大型集合的 Jaccard 相似度
Bloom filter 是一种概率数据结构,用于确定一个项目是否是集合的成员。在比较用户或项目相似度时,我们通常关心的是集合之间的交集,而不是它们的精确内容。MinHash 是一种技术,它可以将一个大集合压缩成一个较小的集合,同时我们仍然能够在压缩后的表示上执行 Jaccard 相似度计算。
让我们看看它是如何工作的,参考 MovieLens 数据集中两个最 prolific 的评分用户。用户 405 和 655 分别评分了 727 和 675 部电影。在下面的代码中,我们提取他们的评分并将其转换为集合,再传递给 Incanter 的 jaccard-index
函数。回顾一下,这个函数返回他们共同评分的电影占所有他们评分的电影的比例:
(defn rated-items [user-ratings id]
(->> (get user-ratings id)
(map :item)))
(defn ex-7-22 []
(let [ratings (load-ratings "ua.base")
user-ratings (group-by :user ratings)
user-a (rated-items user-ratings 405)
user-b (rated-items user-ratings 655)]
(println "User 405:" (count user-a))
(println "User 655:" (count user-b))
(s/jaccard-index (set user-a) (set user-b))))
;; User 405: 727
;; User 655: 675
;; 158/543
两个大的评分集合之间的*似相似度为 29%。让我们看看如何在使用 MinHash 的同时减少这些集合的大小,同时保持它们之间的相似性。
MinHash 算法与 Bloom filter 有很多相似之处。我们的第一个任务是选择 k 个哈希函数。不同于直接对集合表示进行哈希,这些 k 个哈希函数用于对集合中的每个元素进行哈希。对于每个 k 个哈希函数,MinHash 算法存储由任何集合元素生成的最小值。因此,输出是一个包含 k 个数字的集合;每个数字都等于该哈希函数的最小哈希值。输出被称为 MinHash 签名。
下图展示了将两个包含三个元素的集合转换为 k 为 2 的 MinHash 签名的过程:
输入集合中有两个元素与四个唯一元素中的总数相比,这相当于 Jaccard 指数为 0.5。两个集合的 MinHash 签名分别是 #{3, 0}
和 #{3, 55}
,这相当于 Jaccard 指数为 0.33。因此,MinHash 在保持它们之间*似相似性的同时,缩小了输入集合的大小(在此情况下减少了一个元素)。
与 Bloom filter 类似,合适的 k 选择允许你指定可以容忍的精度损失。我们可以使用以下 Clojure 代码实现 MinHash 算法:
(defn hash-function [seed]
(let [f (Hashing/murmur3_32 seed)]
(fn [x]
(-> (.hashUnencodedChars f (str x))
(.asInt)))))
(defn hash-functions [k]
(map hash-function (range k)))
(defn pairwise-min [a b]
(map min a b))
(defn minhasher [k]
(let [f (apply juxt (hash-functions k))]
(fn [coll]
(->> (map f coll)
(reduce pairwise-min)))))
在下面的代码中,我们定义了一个 k 为 10 的 minhasher
函数,并使用它对用户 405 和 655 的压缩评分进行集合测试,计算 Jaccard 指数:
(defn ex-7-23 []
(let [ratings (load-ratings "ua.base")
user-ratings (group-by :user ratings)
minhash (minhasher 10)
user-a (minhash (rated-items user-ratings 405))
user-b (minhash (rated-items user-ratings 655))]
(println "User 405:" user-a)
(println "User 655:" user-b)
(s/jaccard-index (set user-a) (set user-b))))
;; User 405: #{-2147145175 -2141119028 -2143110220 -2143703868 –
;; 2144897714 -2145866799 -2139426844 -2140441272 -2146421577 –
;; 2146662900}
;; User 655: #{-2144975311 -2140926583 -2141119028 -2141275395 –
;; 2145738774 -2143703868 -2147345319 -2147134300 -2146421577 –
;; 2146662900}
;; 1/4
基于我们的 MinHash 签名计算的 Jaccard 指数与原始集合的 Jaccard 指数非常接*——25% 对比 29%——尽管我们将集合压缩到了只有 10 个元素。
更小集合的好处是双重的:显然,存储空间大大减少,但检查两个集合之间相似度所需的计算复杂度也大大降低。检查只包含 10 个元素的集合相似度,比检查包含数百个元素的集合要轻松得多。因此,MinHash 不仅是一个节省空间的算法,还是一个节省时间的算法,特别是在我们需要进行大量集合相似度测试的情况下;例如,在推荐系统中就经常会遇到这种情况。
如果我们试图为推荐项目建立用户邻域,我们仍然需要执行大量的集合测试,以确定哪些用户最相似。事实上,对于大量用户来说,即使我们已经计算了 MinHash 签名,逐一检查每个其他用户仍可能耗费大量时间。因此,最终的概率技术将着眼于解决这个具体问题:如何在寻找相似项时减少必须比较的候选数量。
通过局部敏感哈希减少配对比较
在上一章中,我们计算了大量文档的相似度矩阵。对于路透社语料库中的 20,000 个文档来说,这已经是一个耗时的过程。随着数据集大小的翻倍,检查每对项目所需的时间将增加四倍。因此,在大规模进行这种分析时,可能会变得耗时且不可行。
例如,假设我们有一百万个文档,并且我们为每个文档计算了长度为 250 的 MinHash 签名。这意味着我们使用 1,000 字节来存储每个文档。由于所有签名可以存储在 1GB 的内存中,它们都可以存储在主系统内存中以提高速度。然而,存在的文档对,或者需要检查 499,999,500,000 对组合。即使比较两个签名只需微秒级的时间,计算所有相似度的过程仍然需要将* 6 天。
局部敏感哈希(LSH)通过显著减少必须进行的配对比较次数来解决这个问题。它通过将可能具有最小相似度阈值的集合分到同一个桶中来实现;只有分到同一桶中的集合才需要进行相似度检查。
签名分桶
我们认为任何哈希到同一桶的项对都是候选对,并且仅检查候选对的相似度。目标是让只有相似的项成为候选对。哈希到同一桶的不同项将是误报,我们力求将这些误报最小化。哈希到不同桶的相似项将是漏报,我们同样力求将这些漏报最小化。
如果我们已经为项计算了 MinHash 签名,那么一种有效的分桶方法是将签名矩阵划分为由r个元素组成的b个带。下图说明了这一点:
在前一部分中,我们已经编写了生成 MinHash 签名的代码,在 Clojure 中执行 LSH 只是将签名分成一定数量的带,每个带的长度为r。每个带都会被哈希(为简单起见,我们对每个带使用相同的哈希函数),并且哈希到特定的桶中:
(def lsh-hasher (hash-function 0))
(defn locality-sensitive-hash [r]
{:r r :bands {}})
(defn buckets-for [r signature]
(->> (partition-all r signature)
(map lsh-hasher)
(map-indexed vector)))
(defn lsh-assoc [{:keys [r] :as lsh} {:keys [id signature]}]
(let [f (fn [lsh [band bucket]]
(update-in lsh [:bands band bucket] conj id))]
(->> (buckets-for r signature)
(reduce f lsh))))
早期的例子将局部敏感哈希定义为一个简单的映射,包含空的带和一些值r。当我们通过lsh-assoc
将一项与 LSH 关联时,我们根据r的值将签名拆分为带,并确定每个带的桶。该项的 ID 会被添加到每个桶中。桶按带 ID 分组,以便在不同带中共享同一个桶的项不会被一起分桶:
(defn ex-7-24 []
(let [ratings (load-ratings "ua.base")
user-ratings (group-by :user ratings)
minhash (minhasher 27)
user-a (minhash (rated-items user-ratings 13))
lsh (locality-sensitive-hash 3)]
(lsh-assoc lsh {:id 13 :signature user-a})))
;; {:r 3, :bands {8 {220825369 (13)}, 7 {-2054093854 (13)},
;; 6 {1177598806 (13)}, 5 {-1809511158 (13)}, 4 {-143738650 (13)},
;; 3 {-704443054 (13)}, 2 {-1217282814 (13)},
;; 1 {-100016681 (13)}, 0 {1353249231 (13)}}}
前面的例子展示了对用户 13 的签名执行 LSH 的结果,k=27且r=3。返回了 9 个带的桶。接下来,我们向局部敏感哈希中添加更多的项:
(defn ex-7-25 []
(let [ratings (load-ratings "ua.base")
user-ratings (group-by :user ratings)
minhash (minhasher 27)
user-a (minhash (rated-items user-ratings 13))
user-b (minhash (rated-items user-ratings 655))]
(-> (locality-sensitive-hash 3)
(lsh-assoc {:id 13 :signature user-a})
(lsh-assoc {:id 655 :signature user-b}))))
;; {:r 3, :bands {8 {220825369 (655 13)}, 7 {1126350710 (655),
;; -2054093854 (13)}, 6 {872296818 (655), 1177598806 (13)},
;; 5 {-1272446116 (655), -1809511158 (13)}, 4 {-154360221 (655),
;; -143738650 (13)}, 3 {123070264 (655), -704443054 (13)},
;; 2 {-1911274538 (655), -1217282814 (13)}, 1 {-115792260 (655),
;; -100016681 (13)}, 0 {-780811496 (655), 1353249231 (13)}}}
在前面的例子中,我们可以看到用户 ID655
和13
在带8
中被放置在同一个桶中,尽管它们在其他所有带中都在不同的桶中。
一个特定带上签名一致的概率是s^r,其中s是集合的真实相似度,r是每个带的长度。因此,至少在一个特定带上签名不一致的概率是,因此,所有带上签名不一致的概率是
。因此,我们可以说,两项成为候选对的概率是
。
无论b和r的具体值是多少,这个方程描述了一个 S 曲线。阈值(即相似度值,使得成为候选项的概率为 0.5)是b和r的函数。在阈值附*,S 曲线急剧上升。因此,相似度超过阈值的对非常可能成为候选项,而低于阈值的对则相应不太可能成为候选项。
要搜索候选对,我们现在只需对目标签名执行相同的过程,并查看哪些其他项哈希到相同的桶中,在相同的带内:
(defn lsh-candidates [{:keys [bands r]} signature]
(->> (buckets-for r signature)
(mapcat (fn [[band bucket]]
(get-in bands [band bucket])))
(distinct)))
前面的代码返回了与目标签名至少在一个带内共享一个桶的不同项列表:
(defn ex-7-26 []
(let [ratings (load-ratings "ua.base")
user-ratings (group-by :user ratings)
minhash (minhasher 27)
user-b (minhash (rated-items user-ratings 655))
user-c (minhash (rated-items user-ratings 405))
user-a (minhash (rated-items user-ratings 13))]
(-> (locality-sensitive-hash 3)
(lsh-assoc {:id 655 :signature user-b})
(lsh-assoc {:id 405 :signature user-c})
(lsh-candidates user-a))))
;; (655)
在前面的例子中,我们将用户655
和405
的签名关联到局部敏感哈希中。然后,我们查询用户 ID 为13
的候选者。结果是一个包含单一 ID 655
的序列。因此,655
和13
是候选对,需要检查它们的相似性。算法已判断用户405
的相似度不足,因此我们不再检查它们的相似性。
注意
欲了解有关局部敏感哈希、MinHash 及其他用于处理大量数据的有用算法的更多信息,请参考免费的在线书籍《Mining of Massive Datasets》,网址为www.mmds.org/
。
局部敏感哈希是一种显著减少在比较集合相似性时需要进行的成对比较的空间的方法。因此,通过为b和r设置适当的值,局部敏感哈希允许我们预先计算用户邻域。给定目标用户,找到相似用户的任务变得简单:只需找到在任何带子中共享相同桶的其他用户;这个任务的时间复杂度与带子数量相关,而不是与用户数量相关。
降维
像 MinHash 和 LSH 这样的算法旨在减少必须存储的数据量,而不损害原始数据的本质。它们是一种压缩形式,定义了有助于保持我们进行有用工作的表示。特别是,MinHash 和 LSH 旨在处理可以表示为集合的数据。
实际上,有一类降维算法可以处理那些不容易作为集合表示的数据。我们在前一章的 k-means 聚类中看到,某些数据最有效的表示方式是加权向量。常见的降维方法包括主成分分析和奇异值分解。为了演示这些方法,我们将返回 Incanter,并利用其中的一个内置数据集:鸢尾花数据集:
(defn ex-7-27 []
(i/view (d/get-dataset :iris)))
前面的代码应该返回以下表格:
鸢尾花数据集的前四列包含鸢尾花植物的萼片长度、萼片宽度、花瓣长度和花瓣宽度的测量值。数据集按植物的物种排序。第 0 到 49 行代表鸢尾花 Setosa,第 50 到 99 行代表鸢尾花 Virsicolor,100 行以上包含鸢尾花 Virginica。具体物种不重要;我们只关心它们之间的差异。
绘制鸢尾花数据集
让我们在散点图上可视化鸢尾花数据集的一些属性。我们将使用以下辅助函数,将每个物种作为不同颜色绘制:
(defn plot-iris-columns [a b]
(let [data (->> (d/get-dataset :iris)
(i/$ [a b])
(i/to-matrix))]
(-> (c/scatter-plot (i/$ (range 50) 0 data)
(i/$ (range 50) 1 data)
:x-label (name a)
:y-label (name b))
(c/add-points (i/$ (range 50 100) 0 data)
(i/$ (range 50 100) 1 data))
(c/add-points (i/$ [:not (range 100)] 0 data)
(i/$ [:not (range 100)] 1 data))
(i/view))))
定义了这个函数后,让我们看看三个物种的萼片宽度和长度之间的比较:
(defn ex-7-28 []
(plot-iris-columns :Sepal.Width
:Sepal.Length))
前面的例子应该生成如下图表:
在比较这两个属性时,我们可以看到其中一个物种与另外两个物种有很大不同,但两个物种几乎无法区分:几个点的宽度和高度几乎重叠。
让我们改为绘制花瓣的宽度和高度,看看它们的比较情况:
(defn ex-7-29 []
(plot-iris-columns :Petal.Width
:Petal.Length))
这应该生成以下图表:
这样做可以更好地区分不同的物种。这部分是因为花瓣的宽度和长度的方差更大——例如,长度在y轴上延伸了 6 个单位。这个更大范围的一个有用副作用是,它让我们能更清晰地分辨鸢尾花的物种之间的差异。
主成分分析
在主成分分析(Principle Component Analysis,简称 PCA)中,我们的目标是找到一个旋转数据的方法,以最大化方差。在之前的散点图中,我们找到了一个观察数据的方式,它在y轴上提供了较高的方差,但x轴的方差并不大。
在鸢尾花数据集中,我们有四个维度可用,每个维度表示花瓣或萼片的长度和宽度。主成分分析可以帮助我们确定是否存在一个新的基,它是所有可用维度的线性组合,能最好地重新表达我们的数据,以最大化方差。
我们可以通过 Incanter.stats 的principle-components
函数应用主成分分析。在以下代码中,我们传递给它一个数据矩阵,并绘制返回的前两个旋转:
(defn ex-7-30 []
(let [data (->> (d/get-dataset :iris)
(i/$ (range 4))
(i/to-matrix))
components (s/principal-components data)
pc1 (i/$ 0 (:rotation components))
pc2 (i/$ 1 (:rotation components))
xs (i/mmult data pc1)
ys (i/mmult data pc2)]
(-> (c/scatter-plot (i/$ (range 50) 0 xs)
(i/$ (range 50) 0 ys)
:x-label "Principle Component 1"
:y-label "Principle Component 2")
(c/add-points (i/$ (range 50 100) 0 xs)
(i/$ (range 50 100) 0 ys))
(c/add-points (i/$ [:not (range 100)] 0 xs)
(i/$ [:not (range 100)] 0 ys))
(i/view))))
上述例子生成了以下图表:
请注意,轴不再能被标识为萼片或花瓣——这些成分已经通过所有维度的值的线性组合来推导出来,并定义了一个新的基,用来查看数据,从而在每个成分中最大化方差。事实上,principle-component
函数返回了每个维度的:std-dev
以及:rotation
。
注意
若要查看演示主成分分析的互动示例,请访问 setosa.io/ev/principal-component-analysis/
。
通过对数据进行主成分分析,x轴和y轴的方差比之前显示花瓣宽度和长度的散点图还要大。因此,对应于不同鸢尾花物种的点分布尽可能地展开,这样物种之间的相对差异就清晰可见。
奇异值分解
与 PCA 密切相关的一个技术是奇异值分解(SVD)。事实上,SVD 比 PCA 更通用,它也旨在改变矩阵的基。
注意
PCA 及其与 SVD 的关系有一个很好的数学描述,可以参考arxiv.org/pdf/1404.1100.pdf
。
顾名思义,SVD 将一个矩阵分解为三个相关的矩阵,通常称为U、Σ(或S)和V矩阵,满足以下条件:
如果X是一个 m x n 矩阵,U是一个 m x m 矩阵,Σ是一个 m x n 矩阵,V是一个 n x n 矩阵。Σ实际上是一个对角矩阵,意味着除了主对角线(从左上角到右下角)上的元素外,所有单元格都是零。显然,它不一定是方阵。SVD 返回的矩阵的列是按奇异值排序的,最重要的维度排在最前面。因此,SVD 使我们能够通过丢弃最不重要的维度来更*似地表示矩阵X。
例如,我们 150 x 4 的鸢尾花矩阵的分解会得到一个 150 x 150 的U,一个 150 x 4 的Σ和一个 4 x 4 的V。将这些矩阵相乘将得到原始的鸢尾花矩阵。
然而,我们也可以选择只取前两个奇异值,并调整矩阵,使得U是 150 x 2,Σ是 2 x 2,V是 2 x 4。让我们构建一个函数,该函数接受一个矩阵,并通过从每个U、Σ和V矩阵中取出指定数量的列,将其投影到指定的维度中:
(defn project-into [matrix d]
(let [svd (i/decomp-svd matrix)]
{:U (i/$ (range d) (:U svd))
:S (i/diag (take d (:S svd)))
:V (i/trans
(i/$ (range d) (:V svd)))}))
这里,d是我们想要保留的维度数量。我们通过一个简单的示例来演示这一点,假设我们用 Incanter 生成一个多变量正态分布(使用s/sample-mvn
),并将其减少到仅一维:
(defn ex-7-31 []
(let [matrix (s/sample-mvn 100
:sigma (i/matrix [[1 0.8]
[0.8 1]]))]
(println "Original" matrix)
(project-into matrix 1)))
;; Original A 100x2 matrix
;; :U A 100x1 matrix
;; :S A 1x1 matrix
;; :V A 1x2 matrix
上一个示例的输出包含了数据中最重要的方面,这些方面被减少到只有一维。为了在二维中重新创建原始数据集的*似值,我们可以简单地将三个矩阵相乘。在下面的代码中,我们将分布的一维*似值投影回二维:
(defn ex-7-32 []
(let [matrix (s/sample-mvn 100
:sigma (i/matrix [[1 0.8]
[0.8 1]]))
svd (project-into matrix 1)
projection (i/mmult (:U svd)
(:S svd)
(:V svd))]
(-> (c/scatter-plot (i/$ 0 matrix) (i/$ 1 matrix)
:x-label "x"
:y-label "y"
:series-label "Original"
:legend true)
(c/add-points (i/$ 0 projection) (i/$ 1 projection)
:series-label "Projection")
(i/view))))
这会生成以下图表:
注意,SVD 保留了多变量分布的主要特征,即强对角线,但将非对角点的方差压缩了。通过这种方式,SVD 保留了数据中最重要的结构,同时丢弃了不太重要的信息。希望前面的示例比 PCA 示例更清楚地说明,保留的特征不一定在原始数据中显式存在。在这个示例中,强对角线是数据的潜在特征。
注意
潜在特征是那些不能直接观察到的特征,但可以通过其他特征推断出来。有时,潜在特征指的是那些可以直接测量的方面,比如前面示例中的相关性,或者在推荐系统的背景下,它们可以代表潜在的偏好或态度。
在之前观察到的合成数据上已经看到了 SVD 的原理,现在让我们看看它在 Iris 数据集上的表现:
(defn ex-7-33 []
(let [svd (->> (d/get-dataset :iris)
(i/$ (range 4))
(i/to-matrix)
(i/decomp-svd))
dims 2
u (i/$ (range dims) (:U svd))
s (i/diag (take dims (:S svd)))
v (i/trans (i/$ (range dims) (:V svd)))
projection (i/mmult u s v)]
(-> (c/scatter-plot (i/$ (range 50) 0 projection)
(i/$ (range 50) 1 projection)
:x-label "Dimension 1"
:y-label "Dimension 2")
(c/add-points (i/$ (range 50 100) 0 projection)
(i/$ (range 50 100) 1 projection))
(c/add-points (i/$ [:not (range 100)] 0 projection)
(i/$ [:not (range 100)] 1 projection))
(i/view))))
这段代码生成了以下图表:
在比较了 PCA 和 SVD 的 Iris 图后,应该清楚这两种方法是密切相关的。这个散点图看起来非常像我们之前看到的 PCA 图的倒转版本。
让我们回到电影推荐的问题,看看降维如何提供帮助。在下一节中,我们将利用 Apache Spark 分布式计算框架和相关的机器学习库 MLlib,在降维后的数据上进行电影推荐。
使用 Apache Spark 和 MLlib 进行大规模机器学习
Spark 项目(spark.apache.org/
)是一个集群计算框架,强调低延迟作业执行。它是一个相对较新的项目,起源于 2009 年 UC 伯克利的 AMP 实验室。
尽管 Spark 可以与 Hadoop 共存(例如,通过连接存储在 Hadoop 分布式文件系统 (HDFS) 上的文件),但它通过将大部分计算保存在内存中,目标是实现更快的作业执行时间。与 Hadoop 的两阶段 MapReduce 模式不同,后者在每次迭代之间将文件存储在磁盘上,Spark 的内存模型在某些应用中,特别是那些对数据执行多次迭代的应用中,可以提高数十倍甚至数百倍的速度。
在第五章,大数据中,我们发现迭代算法在大数据量的优化技术实现中的价值。这使得 Spark 成为大规模机器学习的优秀选择。实际上,MLlib 库(spark.apache.org/mllib/
)就是建立在 Spark 之上的,内置了多种机器学习算法。
我们在这里不会对 Spark 进行深入讲解,只会解释运行 Spark 作业所需的关键概念,使用的是 Clojure 库 Sparkling(github.com/gorillalabs/sparkling
)。Sparkling 将 Spark 的大部分功能封装在一个友好的 Clojure 接口背后。特别是,使用 ->>
线程最后宏来将 Spark 操作链式组合,使得使用 Sparkling 编写的 Spark 作业看起来更像我们用 Clojure 自身的序列抽象处理数据时写的代码。
注意
还要确保查看 Flambo,它利用线程优先宏来链式组合任务:github.com/yieldbot/flambo
。
我们将基于 MovieLens 的评分数据生成推荐,所以第一步是用 Sparkling 加载这些数据。
使用 Sparkling 加载数据
Spark 可以从 Hadoop 支持的任何存储源加载数据,包括本地文件系统和 HDFS,以及其他数据源,如 Cassandra、HBase 和 Amazon S3。让我们从基础开始,编写一个作业来简单地统计评分数量。
MovieLens 的评分存储为文本文件,可以通过sparkling.core
命名空间中的text-file
函数在 Sparkling 中加载(在代码中称为spark
)。为了告诉 Spark 文件的位置,我们传递一个 URI,指向一个远程源,如hdfs://...
、s3n://...
。由于我们在本地模式下运行 Spark,它也可以是一个本地文件路径。一旦我们获得文本文件,就会调用spark/count
来获取行数:
(defn count-ratings [sc]
(-> (spark/text-file sc "data/ml-100k/ua.base")
(spark/count)))
(defn ex-7-34 []
(spark/with-context sc (-> (conf/spark-conf)
(conf/master "local")
(conf/app-name "ch7"))
(count-ratings sc)))
;; 90570
如果你运行前面的示例,可能会看到很多来自 Spark 的日志打印到控制台。最后几行中会显示已计算的计数。
请注意,我们必须将 Spark 上下文作为第一个参数传递给text-file
函数。Spark 上下文告诉 Spark 如何访问你的集群。最基本的配置指定了 Spark 主节点的位置和 Spark 应该为此作业使用的应用程序名称。对于本地运行,Spark 主节点为"local"
,这对于基于 REPL 的交互式开发非常有用。
映射数据
Sparkling 提供了许多 Clojure 核心序列函数的类比,如 map、reduce 和 filter。在本章开始时,我们将评分存储为一个包含:item
、:user
和:rating
键的映射。虽然我们可以再次将数据解析为映射,但让我们改为将每个评分解析为Rating
对象。这将使我们更容易在本章后面与 MLlib 进行交互。
Rating
类定义在org.apache.spark.mllib.recommendation
包中。构造函数接受三个数字参数:用户、项目的表示,以及用户对该项目的评分。除了创建一个Rating
对象外,我们还计算了时间模10
,返回一个介于 0 和 9 之间的数字,并创建一个包含这两个值的tuple
:
(defn parse-rating [line]
(let [[user item rating time] (->> (str/split line #"\t")
(map parse-long))]
(spark/tuple (mod time 10)
(Rating. user item rating))))
(defn parse-ratings [sc]
(->> (spark/text-file sc "data/ml-100k/ua.base")
(spark/map-to-pair parse-rating)))
(defn ex-7-35 []
(spark/with-context sc (-> (conf/spark-conf)
(conf/master "local")
(conf/app-name "ch7"))
(->> (parse-ratings sc)
(spark/collect)
(first))))
;; #sparkling/tuple [8 #<Rating Rating(1,1,5.0)>]
返回的值是一个元组,包含一个整数键(定义为时间模10
)和一个评分作为值。拥有一个将数据分为十个组的键,在我们将数据拆分为测试集和训练集时将会非常有用。
分布式数据集和元组
Spark 广泛使用元组来表示键值对。在前面的示例中,键是一个整数,但这不是必须的——键和值可以是任何 Spark 可序列化的类型。
在 Spark 中,数据集被表示为 弹性分布式数据集(RDDs)。事实上,RDD 是 Spark 提供的核心抽象——它是一个容错的记录集合,分布在集群中的所有节点上,可以并行操作。RDD 有两种基本类型:一种表示任意对象的序列(例如 text-file
返回的那种——一系列行),另一种表示键/值对的序列。
我们可以简单地在普通 RDD 和配对 RDD 之间进行转换,这在前面的示例中通过 map-to-pair
函数实现。我们 parse-rating
函数返回的元组指定了每对序列中的键和值。与 Hadoop 一样,键在数据集中不要求是唯一的。实际上,正如我们将看到的,键通常是将相似记录分组在一起的有用手段。
过滤数据
现在我们根据键的值来过滤数据,并创建一个可以用于训练的子集。与同名的 Clojure 核心函数类似,Sparkling 提供了一个filter
函数,它只保留那些谓词返回逻辑真值的行。
给定我们评分的配对 RDD,我们可以仅过滤出那些键值小于 8 的评分。由于这些键大致是均匀分布在 0 到 9 之间的整数,这样会保留大约 80% 的数据集:
(defn training-ratings [ratings]
(->> ratings
(spark/filter (fn [tuple]
(< (s-de/key tuple) 8)))
(spark/values)))
我们的评分存储在一个配对 RDD 中,因此 filter
的结果也是一个配对 RDD。我们在结果上调用 values
,以便得到一个只包含 Rating
对象的普通 RDD。这将是我们传递给机器学习算法的 RDD。我们进行完全相同的过程,但对于键值大于或等于 8 的情况,得到我们将用作测试数据的数据集。
持久化与缓存
Spark 的操作是惰性执行的,只有在需要时才会计算。同样,一旦数据被计算,Spark 不会显式地缓存这些数据。不过,有时我们希望保留数据,特别是当我们运行一个迭代算法时,我们不希望每次执行迭代时都从源头重新计算数据集。在需要保存转换后的数据集结果以便在作业中后续使用的情况下,Spark 提供了持久化 RDD 的能力。与 RDD 本身一样,持久化是容错的,这意味着如果任何分区丢失,它将使用最初创建它的转换重新计算。
我们可以使用 spark/persist
函数持久化一个 RDD,该函数要求我们传递 RDD 并配置最适合我们应用的存储级别。在大多数情况下,这将是内存存储。但在需要重新计算数据会非常耗费计算资源的情况下,我们可以将数据溢出到磁盘,甚至在多个磁盘之间复制缓存以实现快速的故障恢复。内存存储是最常见的,因此 Sparkling 提供了spark/cache
函数简写,可以在 RDD 上设置该存储级别:
(defn ex-7-36 []
(spark/with-context sc (-> (conf/spark-conf)
(conf/master "local")
(conf/app-name "ch7"))
(let [ratings (spark/cache (parse-ratings sc))
train (training-ratings ratings)
test (test-ratings ratings)]
(println "Training:" (spark/count train))
(println "Test:" (spark/count test)))))
;; Training: 72806
;; Test: 8778
在前面的示例中,我们缓存了对parse-ratings
函数的调用结果。这意味着加载和解析评分的操作只执行一次,训练和测试评分函数都使用缓存的数据来筛选和执行计数。调用cache
优化了作业的性能,并允许 Spark 避免不必要的重复计算。
Spark 上的机器学习与 MLlib
我们现在已经掌握了 Spark 的基础知识,可以使用我们的 RDD 进行机器学习。虽然 Spark 处理基础设施,但执行机器学习的实际工作由一个名为 MLlib 的 Apache Spark 子项目来完成。
注意
MLlib 库的所有功能概览可以参考spark.apache.org/docs/latest/mllib-guide.html
。
MLlib 提供了丰富的机器学习算法供 Spark 使用,包括回归、分类和聚类算法,这些算法在本书其他部分中都有介绍。在本章中,我们将使用 MLlib 提供的算法来执行协同过滤:交替最小二乘法。
使用交替最小二乘法进行电影推荐
在第五章,大数据中,我们发现如何使用梯度下降法来识别最小化成本函数的大量数据的参数。在本章中,我们看到如何使用 SVD 通过分解来计算数据矩阵中的潜在因子。
交替最小二乘法(ALS)算法可以看作是这两种方法的结合。它是一个迭代算法,使用最小二乘法估计来将用户-电影评分矩阵分解成两个潜在因子矩阵:用户因子和电影因子。
交替最小二乘法基于假设,用户的评分是基于某种电影的潜在属性,而这种属性无法直接衡量,但可以从评分矩阵中推断出来。前面的图表显示了如何将用户-电影评分的稀疏矩阵分解成包含用户因子和电影因子的两个矩阵。该图表为每个用户和电影关联了三个因子,但我们可以通过使用两个因子来简化这个过程。
我们可以假设所有电影存在于一个二维空间中,由它们的动作水*、浪漫元素以及它们的现实主义程度(或非现实主义程度)来标定。我们可以将这种空间可视化如下:
我们同样可以将所有用户想象成一个等效的二维空间,在这个空间中,他们的口味仅仅通过他们对浪漫/动作和现实主义/逃避现实的相对偏好来表达。
一旦我们将所有电影和用户降维为它们的因子表示,预测问题就简化为一个简单的矩阵乘法——给定一部电影和一个用户,我们的预测评分就是它们因子的乘积。对于 ALS 来说,挑战就在于计算这两个因子矩阵。
使用 Spark 和 MLlib 进行 ALS
在撰写本文时,MLlib 库没有 Clojure 包装器,因此我们将使用 Clojure 的互操作能力直接访问它。MLlib 中交替最小二乘法的实现由org.apache.spark.mllib.recommendation
包中的 ALS 类提供。训练 ALS 几乎就像调用该类的train
静态方法,传入我们的 RDD 和提供的参数一样简单:
(defn alternating-least-squares [data {:keys [rank num-iter
lambda]}]
(ALS/train (to-mllib-rdd data) rank num-iter lambda 10))
一点复杂性在于,我们之前 Sparkling 任务返回的训练数据 RDD 是以JavaRDD
类型表示的。由于 MLlib 没有 Java API,它期望接收标准的 Spark RDD
类型。将两者之间进行转换是一个相对简单的过程,尽管稍微有点繁琐。以下函数可以在 RDD 类型之间来回转换;将数据转换成 MLlib 可以使用的RDDs
,然后再转换回JavaRDDs
以供 Sparkling 使用:
(defn to-mlib-rdd [rdd]
(.rdd rdd))
(defn from-mlib-rdd [rdd]
(JavaRDD/fromRDD rdd scala/OBJECT-CLASS-TAG))
from-mllib-rdd
中的第二个参数是一个在scalaInterop
命名空间中定义的值。这是与 Scala 函数定义生成的 JVM 字节码交互所必需的。
注意
如需了解更多关于 Clojure/Scala 互操作的信息,请查阅 Tobias Kortkamp 提供的出色资料,来源于scala
库:t6.github.io/from-scala/
。
在完成前面的模板代码之后,我们终于可以在训练评分上执行 ALS 了。我们在以下示例中进行此操作:
(defn ex-7-37 []
(spark/with-context sc (-> (conf/spark-conf)
(conf/master "local")
(conf/app-name "ch7"))
(-> (parse-ratings sc)
(training-ratings)
(alternating-least-squares {:rank 10
:num-iter 10
:lambda 1.0}))))
该函数接受几个参数—rank
、num-iter
和lambda
,并返回一个 MLlib 的MatrixFactorisationModel
函数。rank 是用于因子矩阵的特征数量。
使用 ALS 进行预测
一旦我们计算了MatrixFactorisationModel
,就可以使用它通过recommendProducts
方法进行预测。该方法期望接收推荐目标用户的 ID 以及要返回的推荐数量:
(defn ex-7-38 []
(spark/with-context sc (-> (conf/spark-conf)
(conf/master "local")
(conf/app-name "ch7"))
(let [options {:rank 10
:num-iter 10
:lambda 1.0}
model (-> (parse-ratings sc)
(training-ratings )
(alternating-least-squares options))]
(into [] (.recommendProducts model 1 3)))))
;; [#<Rating Rating(1,1463,3.869355232995907)>
;; #<Rating Rating(1,1536,3.7939806028920993)>
;; #<Rating Rating(1,1500,3.7130689437266646)>]
你可以看到,模型的输出像输入一样,是Rating
对象。它们包含用户 ID、项目 ID 和预测评分,该评分是因子矩阵的乘积。我们可以利用在本章开始时定义的函数,为这些评分命名:
(defn ex-7-39 []
(spark/with-context sc (-> (conf/spark-conf)
(conf/master "local")
(conf/app-name "ch7"))
(let [items (load-items "u.item")
id->name (fn [id] (get items id))
options {:rank 10
:num-iter 10
:lambda 1.0}
model (-> (parse-ratings sc)
(training-ratings )
(alternating-least-squares options))]
(->> (.recommendProducts model 1 3)
(map (comp id->name #(.product %)))))))
;; ("Boys, Les (1997)" "Aiqing wansui (1994)"
;; "Santa with Muscles (1996)")
然而,这些推荐看起来并不特别好。为此,我们需要评估 ALS 模型的性能。
评估 ALS
与 Mahout 不同,Spark 没有内置的模型评估器,因此我们需要自己编写一个。最简单的评估器之一,也是我们在本章中已经使用过的,就是均方根误差(RMSE)评估器。
我们评估的第一步是使用模型预测我们所有训练集的评分。Spark 的 ALS 实现包括一个predict
函数,我们可以使用它,该函数接受一个包含所有用户 ID 和商品 ID 的 RDD,并返回预测值:
(defn user-product [rating]
(spark/tuple (.user rating)
(.product rating)))
(defn user-product-rating [rating]
(spark/tuple (user-product rating)
(.rating rating)))
(defn predict [model data]
(->> (spark/map-to-pair user-product data)
(to-mlib-rdd data)
(.predict model)
(from-mlib-rdd)
(spark/map-to-pair user-product-rating)))
我们之前调用的.recommendProducts
方法使用模型为特定用户返回产品推荐。相比之下,.predict
方法将同时预测多个用户和商品的评分。
我们调用.predict
函数的结果是一个键值对 RDD,其中键本身是一个包含用户和产品的元组。该对 RDD 的值是预测评分。
计算*方误差之和
为了计算预测评分与用户给出的实际评分之间的差异,我们需要根据匹配的用户/商品元组将predictions
和actuals
连接起来。由于这两个 RDD 的键是相同的,我们可以直接将它们传递给 Sparkling 的join
函数:
(defn squared-error [y-hat y]
(Math/pow (- y-hat y) 2))
(defn sum-squared-errors [predictions actuals]
(->> (spark/join predictions actuals)
(spark/values)
(spark/map (s-de/val-val-fn squared-error))
(spark/reduce +)))
我们可以将整个sum-squared-errors
函数可视化为以下流程,比较预测评分和实际评分:
一旦我们计算了sum-squared-errors
,计算均方根误差就只是将其除以计数并取*方根的过程:
(defn rmse [model data]
(let [predictions (spark/cache (predict model data))
actuals (->> (spark/map-to-pair user-product-rating
data)
(spark/cache))]
(-> (sum-squared-errors predictions actuals)
(/ (spark/count data))
(Math/sqrt))))
rmse
函数将接受一个模型和一些数据,并计算预测值与实际评分之间的 RMSE。在本章之前,我们绘制了随着用户推荐器邻域大小变化,RMSE 的不同值。现在让我们使用相同的技巧,但改变因子矩阵的秩:
(defn ex-7-40 []
(spark/with-context sc (-> (conf/spark-conf)
(conf/master "local")
(conf/app-name "ch7"))
(let [options {:num-iter 10 :lambda 0.1}
training (-> (parse-ratings sc)
(training-ratings)
(spark/cache))
ranks (range 2 50 2)
errors (for [rank ranks]
(doto (-> (als training
(assoc options :rank rank))
(rmse training))
(println "RMSE for rank" rank)))]
(-> (c/scatter-plot ranks errors
:x-label "Rank"
:y-label "RMSE")
(i/view)))))
之前的代码生成了如下图表:
注意,随着因子矩阵的秩增加,我们模型返回的评分越来越接*于模型训练时使用的评分。随着因子矩阵维度的增长,它可以捕捉到更多个体用户评分中的变异。
然而,我们真正想做的是看看推荐系统在测试集上的表现——即它之前没有见过的数据。本章的最后一个示例ex-7-41
再次执行前面的分析,但测试的是模型在测试集上的 RMSE,而不是训练集上的 RMSE。该示例生成了如下图表:
正如我们所期望的那样,随着因子矩阵的秩增加,预测的 RMSE 逐渐下降。较大的因子矩阵能够捕捉到评分中的更多潜在特征,从而更准确地预测用户给电影的评分。
概述
本章我们讨论了很多内容。尽管主题主要是推荐系统,我们也涉及了降维技术,并介绍了 Spark 分布式计算框架。
我们首先讨论了基于内容过滤和协同过滤两种推荐方法的区别。在协同过滤的背景下,我们讨论了物品-物品推荐器,并构建了一个 Slope One 推荐器。我们还讨论了用户-用户推荐器,并使用 Mahout 的各种相似度度量和评估器的实现来实现并测试了几种基于用户的推荐器。评估的挑战为我们提供了一个引入信息检索统计学的机会。
本章我们花了很多时间讨论不同类型的降维方法。例如,我们学习了 Bloom 过滤器和 MinHash 提供的概率方法,以及主成分分析和奇异值分解提供的分析方法。虽然这些方法并非专门针对推荐系统,但我们看到这些技术如何帮助实现更高效的相似度比较。
最后,我们介绍了分布式计算框架 Spark,并学习了交替最小二乘算法如何利用降维技术在评分矩阵中发现潜在因子。我们使用 Spark、MLlib 和 Clojure 库 Sparkling 实现了 ALS 和 RMSE 评估器。
本章所学的许多技术非常通用,下一章也不会例外。我们将继续探索 Spark 和 Sparkling 库,学习网络分析:即连接和关系的研究。
第八章 网络分析
"敌人的敌人就是我的朋友。" | ||
---|---|---|
--古老的谚语 |
本章关注的是数学意义上的图,而非视觉意义上的图。图只是由顶点和边连接组成的集合,这种抽象的简洁性使得图无处不在。它们是各种结构的有效模型,包括网页的超链接结构、互联网的物理结构,以及各种网络:道路、通信、社交网络等。
因此,网络分析并不是什么新鲜事,但随着社交网络分析的兴起,它变得尤为流行。网络上最大的站点之一就是社交网络,Google、Facebook、Twitter 和 LinkedIn 都利用大规模图处理来挖掘用户数据。针对网站变现的精准广告需求使得公司在有效推断互联网用户兴趣方面获得了巨大的财务回报。
在本章中,我们将使用公开的 Twitter 数据来演示网络分析的原理。我们将应用如三角计数这样的模式匹配技术,在图中寻找结构,并应用如标签传播和 PageRank 这样的整体图处理算法,以揭示图的网络结构。最终,我们将使用这些技术从 Twitter 社区中最具影响力的成员识别出它们的兴趣。我们将使用 Spark 和一个名为 GraphX 的库来完成这些操作,GraphX 利用 Spark 分布式计算模型来处理非常大的图。
但在我们扩展规模之前,我们将通过考虑一种不同类型的问题来开始对图的探索:图遍历问题。为此,我们将使用 Clojure 库 Loom。
下载数据
本章使用来自 Twitter 社交网络的关注者数据。这些数据是作为斯坦福大规模网络数据集的一部分提供的。你可以从snap.stanford.edu/data/egonets-Twitter.html
下载 Twitter 数据。
我们将使用twitter.tar.gz
文件和twitter_combined.txt.gz
文件。这两个文件应该被下载并解压到示例代码的 data 目录中。
注意
本章的示例代码可在github.com/clojuredatascience/ch8-network-analysis
找到。
如常所示,我们提供了一个脚本,可以为你完成这项工作。你可以通过在项目目录中执行以下命令来运行它:
script/download-data.sh
如果你想运行本章的示例,请确保在继续之前下载数据。
检查数据
让我们来看一下 Twitter 目录中的一个文件,特别是twitter/98801140.edges
文件。如果你在文本编辑器中打开它,你会看到文件的每一行都是由一对整数组成,并且它们之间用空格分隔。这些数据采用的是所谓的边列表格式。这是存储图的两种主要方式之一(另一种是邻接表格式,我们稍后会讲到)。下面的代码使用 Clojure 的line-seq
函数逐行读取文件,并将其转换为元组:
(defn to-long [l]
(Long/parseLong l))
(defn line->edge [line]
(->> (str/split line #" ")
(mapv to-long)))
(defn load-edges [file]
(->> (io/resource file)
(io/reader)
(line-seq)
(map line->edge)))
(defn ex-8-1 []
(load-edges "twitter/98801140.edges"))
如果你在 REPL 中执行(ex-8-1)
,或在命令行中运行以下命令,你应该看到以下序列:
lein run –e 8.1
;;([100873813 3829151] [35432131 3829151] [100742942 35432131]
;; [35432131 27475761] [27475761 35432131])
这组简单的数字对,每个都表示一条边,已经足够表示图的本质。虽然不直观地看到边与边之间的关系,但我们可以通过可视化来帮助理解。
使用 Loom 可视化图形
在本章的前半部分,我们将使用 Loom(github.com/aysylu/loom
)来处理我们的图形。Loom 定义了一个 API,用于创建和操作图形。它还包含许多内建的图遍历算法。我们很快会介绍这些。
首先,我们要可视化我们的图。为此,Loom 依赖于一个名为 GraphViz 的系统级库。如果你想复制本章中的许多图像,你需要先安装 GraphViz。如果你不确定是否已经安装,可以尝试在命令行中运行以下命令:
dot –V
注意
GraphViz 可以从graphviz.org/
下载,并且提供了适用于 Linux、MacOS 和 Windows 的安装程序。GraphViz 不是运行本章所有示例的必需工具,只是那些需要可视化图形的示例。
Loom 能够从一系列边创建图,就像我们在将loom/graph
函数应用于该序列时所做的那样。接下来,我们将需要将loom.graph
引用为loom
,并将loom.io
引用为lio
。如果你安装了 GraphViz,可以运行以下示例:
(defn ex-8-2 []
(->> (load-edges "twitter/98801140.edges")
(apply loom/graph)
(lio/view)))
你应该看到类似以下示意图的结果:
根据你安装的 GraphViz 版本,你可能无法获得与之前版本完全相同的布局,但这没关系。图中节点和边的相对位置并不重要。图的唯一重要事实是哪些节点连接到哪些其他节点。
作为一个 Clojure 程序员,你熟悉树结构作为 S 表达式的嵌套结构,你可能已经注意到这个图看起来像一棵树。事实上,树只是图的一种特殊形式:它不包含环。我们称这样的图为无环图。
在这个图中,只有四条边,而在我们在第一个例子中看到的边列表中有五条边。这是因为边可以是有向的。它们从一个节点指向另一个节点。我们可以使用loom/digraph
函数加载有向图:
(defn ex-8-3 []
(->> (load-edges "twitter/98801140.edges")
(apply loom/digraph)
(lio/view)))
这段代码生成了以下图像:
注意到为我们的边添加方向的行为已经从根本上改变了我们阅读图形的方式。特别是,图形显然不再是树形结构。有向图在我们希望表示某种操作由某个事物对另一个事物执行的情况下非常重要。
例如,在 Twitter 的社交图中,一个账户可以关注另一个账户,但这一行为不一定是互惠的。使用 Twitter 的术语,我们可以指一个账户的粉丝或朋友。关注表示出边,而朋友表示入边。例如,在之前的图中,账户 382951 有两个粉丝:账户 35432131 和 100873813。
现在节点 27475761 和 35432131 之间有两条边。这意味着可以从一个节点回到另一个节点。我们称之为一个环。像之前的图这样的图的技术术语是有向环形图。
注意
图中的一个环表示可以仅通过沿边的方向移动而返回到某个节点。如果图中没有这样的环路,那么图就被称为无环图。有向无环图(DAG)是许多层次结构或有序现象的模型,例如依赖关系图、家谱树和文件系统层次结构。
我们已经看到,图形可以是有向的或无向的。图形的第三种主要类型是加权图。可以将权重有用地关联到边上,以表示两个节点之间连接的强度。例如,如果图形表示社交网络,则两个账户之间的权重可能表示它们的连接强度(例如,它们的沟通频率)。
我们可以使用 loom
中的 loom/weighted-graph
或 loom/weighted-digraph
函数加载加权图:
(defn ex-8-4 []
(->> (load-edges "twitter/98801140.edges")
(apply loom/weighted-digraph)
(lio/view)))
我们的输入图形实际上并没有指定边的权重。Loom 的所有边的默认权重是1。
图形的另一个区别在于其顶点和边是否有类型,表示它们之间不同的实体或连接。例如,Facebook 图形包含多种类型的实体:特别是“页面”和“人”。人可以“点赞”页面,但不能“点赞”其他人。在异构图中,当类型为“A”的节点总是连接到类型为“B”的节点,而类型“A”的节点从不与其他类型的“A”节点连接时,这种图被称为二分图。二分图可以表示为两个不相交的集合,其中一个集合中的节点仅与另一个集合中的节点链接。
使用 Loom 进行图遍历
遍历算法关注的是以系统化的方式探索图形的方法。鉴于图形能够建模的现象种类繁多,这些算法可能有多种不同的用途。
我们将在接下来的几个章节中讨论一些最常见的任务,例如:
-
确定是否存在一条恰好经过每条边一次的路径
-
确定两个顶点之间的最短路径
-
确定连接所有顶点的最短树
如果所讨论的图表示的是一个快递员的送货路线,那么顶点可以代表交叉口。找到一条恰好经过每条边一次的路径,就是快递员在不返回或重复经过相同地址的情况下,走遍所有道路的方式。两个顶点之间的最短路径将是从一个地址到下一个送货地址的最有效路线。最终,连接所有顶点的最短树将是连接所有顶点的最有效方式:例如,可能是为每个交叉口铺设路边电力线。
昆士堡的七座桥
普鲁士的昆士堡(现在的俄罗斯加尔东格勒)坐落在普雷格尔河的两岸,包含两座大岛,这两座岛通过七座桥与彼此和大陆相连。昆士堡的七座桥是一个历史上著名的数学问题,它为图论奠定了基础,并预示了拓扑学的思想。普雷格尔这个名字稍后将在本章中再次出现。
这个问题是要找到一条穿越城市的路线,这条路线每座桥只经过一次且仅经过一次。岛屿不能通过除桥梁之外的任何路线到达,而且每座桥都必须完全经过;不能走到桥的一半然后转身,再从另一侧走过另一半(虽然这条路线不必从同一个地方开始和结束)。
欧拉意识到这个问题没有解:通过这些桥梁无法找到一条不重复的路线,这一难题促使了一个技术的发展,该技术通过数学严格性建立了这一断言。问题中唯一重要的结构是桥梁和陆地之间的连接。这个问题的本质可以通过将桥梁表示为图中的边来保留。
欧拉观察到(除了行走的起点和终点),一个人通过一条边进入一个顶点,并通过另一条边离开该顶点。如果每条边都恰好经过一次,那么每个节点的连接边数必须是偶数(其中一半是“向内”经过,另一半是“向外”经过)。
因此,要在图中存在欧拉巡回,所有节点(除了起始和结束节点外)必须有偶数条连接边。我们将连接边的数量称为节点的度数。确定图中是否存在欧拉巡回,实际上就是计算奇数度节点的数量。如果存在零个或两个奇数度的节点,则可以从图中构建欧拉巡回。以下函数利用了 Loom 提供的两个实用函数out-degree
和nodes
,来检查图中是否存在欧拉巡回:
(defneuler-tour? [graph]
(let [degree (partial loom/out-degree graph)]
(->> (loom/nodes graph)
(filter (comp odd? degree))
(count)
(contains? #{0 2}))))
在这段代码中,我们使用了 Loom 的out-degree
函数来计算图中每个节点的度数。我们仅筛选出奇数
度数的节点,并验证计数是否为0
或2
。如果是,则图中存在欧拉巡回。
广度优先和深度优先搜索
上面的示例在历史上很有意义,但在图遍历中更常见的需求是从图中的某个节点开始,查找图内的另一个节点。有几种方法可以解决这个问题。对于像我们的 Twitter 关注图这样的无权图,最常见的方法是广度优先搜索和深度优先搜索。
广度优先搜索从一个特定的顶点开始,然后搜索其每个邻居以查找目标顶点。如果没有找到该顶点,它会依次搜索每个邻居的邻居,直到找到顶点或整个图遍历完成为止。
下图展示了顶点被遍历的顺序,从上到下按层次从左到右进行:
Loom 包含了loom.alg
命名空间中的多种遍历算法。我们将对之前一直在研究的 Twitter 关注者图执行广度优先搜索,为了方便起见,图再次被重复展示:
广度优先遍历作为bf-traverse
函数提供。该函数将返回一个顶点序列,按照访问的顺序排列,这将帮助我们查看广度优先搜索是如何遍历图的:
(defn ex-8-5 []
(let [graph (->> (load-edges "twitter/98801140.edges")
(apply loom/digraph))]
(alg/bf-traverse graph 100742942)))
;;(100742942 35432131 27475761 3829151)
我们正在使用bf-traverse
函数执行图的遍历,起始节点为100742942
。注意,响应中没有包含节点100873813
。沿着边的方向无法遍历到此顶点。要到达顶点100742942
,必须从该点开始。
另外,注意到35432131
只列出了一次,尽管它与27475761
和3829151
都相连。Loom 实现的广度优先搜索在内存中维护了一个已访问节点的集合。一旦一个节点被访问,就不需要再访问它。
广度优先搜索的另一种替代方法是深度优先搜索。该算法立即向树的底部推进,并按照下图所示的顺序访问节点:
Loom 包括一个深度优先搜索,作为 pre-traverse
:
(defn ex-8-6 []
(let [graph (->> (load-edges "twitter/98801140.edges")
(apply loom/digraph))]
(alg/pre-traverse graph 100742942)))
;;(100742942 35432131 3829151 27475761)
深度优先搜索的优点在于它的内存需求远低于广度优先搜索,因为它不需要在每一层存储所有节点。这可能使得它在处理大型图时,内存消耗更少。
然而,根据具体情况,深度优先搜索或广度优先搜索可能更方便。例如,如果我们在遍历家谱,寻找一位在世的亲戚,我们可以假设那个人会在家谱的底部,那么深度优先搜索可能会更快找到目标。如果我们在寻找一位古老的祖先,那么深度优先搜索可能会浪费时间检查许多较*的亲戚,花费更长时间才能到达目标。
查找最短路径
前面介绍的算法逐个遍历图的每个节点,并返回一个懒加载的节点序列。它们适合用来演示两种主要的图结构导航方式。然而,常见的任务是寻找从一个顶点到另一个顶点的最短路径。这意味着我们只关心那些位于这两个节点之间的路径。
如果我们有一个无权图,比如前面提到的图,我们通常将距离计算为“跳数”:一跳是两个邻接节点之间的步长。最短路径将有最少的跳数。一般来说,广度优先搜索在这种情况下是一个更高效的算法。
Loom 实现了广度优先最短路径算法,作为 bf-path
函数。为了演示这一点,让我们加载一个更复杂的 Twitter 图:
(defn ex-8-7 []
(->> (load-edges "twitter/396721965.edges")
(apply loom/digraph)
(lio/view)))
这段代码生成了以下图:
让我们看看能否找出顶端和底端节点之间的最短路径:75914648 和 32122637。算法可能返回许多路径,但我们想找到通过 28719244 和 163629705 两个点的路径。这是经过的跳数最少的路径。
(defn ex-8-8 []
(let [graph (->> (load-edges "twitter/396721965.edges")
(apply loom/digraph))]
(alg/bf-path graph 75914648 32122637)))
;;(75914648 28719244 163629705 32122637)
确实是这样。
注
Loom 还实现了一个双向广度优先最短路径算法,作为 bf-path-bi
。它从源点和目的地同时开始搜索,在某些类型的图上,这种方法可以更快地找到最短路径。
如果图是加权图怎么办?在这种情况下,最少的跳数可能并不等于最短路径,因为该路径可能与较大的权重相关联。在这种情况下,Dijkstra 算法是一种用于查找两个节点之间最短代价路径的方法。该路径可能需要更多的跳数,但所经过的边的权重总和会是最低的:
(defn ex-8-9 []
(let [graph (->> (load-edges "twitter/396721965.edges")
(apply loom/weighted-digraph))]
(-> (loom/add-edges graph [28719244 163629705 100])
(alg/dijkstra-path 75914648 32122637))))
;;(75914648 28719244 31477674 163629705 32122637)
在这段代码中,我们将图加载为一个加权有向图,并将节点 28719244
和 163629705
之间的边的权重更新为 100
。所有其他边的默认权重为 1。这会导致为最直接的路径分配一个非常高的成本,因此找到了一条替代路径。
Dijkstra 算法在路线寻找中尤为有价值。例如,如果图表示的是道路网络,最佳路线可能是通过主要道路,而不是走步数最少的路线。或者,根据一天中的时间和道路上的交通量,特定路线的成本可能会发生变化。在这种情况下,Dijkstra 算法可以随时确定最佳路线。
注意
一种名为A*(读作 A-star)的算法通过允许启发式函数优化了 Dijkstra 算法。它在 Loom 中实现为 alg/astar-path
。启发式函数返回预期的目标成本。任何函数都可以作为启发式函数,只要它不会高估真实的成本。使用此启发式函数可以使 A* 算法避免对图进行穷尽性搜索,从而大大加快速度。有关 A* 算法的更多信息,请参考 en.wikipedia.org/wiki/A*_search_algorithm
。
让我们继续考虑带权图,并探讨如何构建一棵树,以最短成本连接所有节点。这样的树被称为最小生成树。
最小生成树
借助前面提到的算法,我们考虑了如何在两点之间遍历图。然而,如果我们想发现一条连接图中所有节点的路径呢?在这种情况下,我们可以使用最小生成树。我们可以将最小生成树视为我们之前考虑的完全图遍历算法和最*看到的最短路径算法的结合体。
最小生成树特别适用于带权图。如果权重表示连接两个顶点的成本,最小生成树则找到连接整个图的最小成本。它们在诸如网络设计等问题中很有用。例如,如果节点表示办公室,而边的权重表示办公室之间电话线的费用,那么最小生成树将提供一组电话线路,以最低的总成本连接所有办公室。
Loom 实现的最小生成树使用了普里姆算法,并作为 prim-mst
函数提供:
(defn ex-8-10 []
(let [graph (->> (load-edges "twitter/396721965.edges")
(apply loom/weighted-graph))]
(-> (alg/prim-mst graph)
(lio/view))))
这将返回以下图表:
如果我们再次将顶点 28719244 和 163629705 之间的边更新为 100,我们将能够观察到它对最小生成树的影响:
(defn ex-8-11 []
(let [graph (->> (load-edges "twitter/396721965.edges")
(apply loom/weighted-graph))]
(-> (loom/add-edges graph [28719244 163629705 100])
(alg/prim-mst)
(lio/view))))
这段代码返回以下图表:
该树已被重新配置,以绕过具有最高成本的边缘。
子图和连通分量
最小生成树只能为连通图指定,其中所有节点通过至少一条路径连接到其他所有节点。在图形不连通的情况下,显然无法构建最小生成树(尽管我们可以构建最小生成森林)。
如果一个图包含一组内部相连但彼此之间没有连接的子图,那么这些子图被称为连通分量。我们可以通过加载一个更复杂的网络来观察连通分量:
(defn ex-8-12 []
(->> (load-edges "twitter/15053535.edges")
(apply loom/graph)
(lio/view)))
这个示例生成了以下图像:
由于图形的布局,我们可以轻松看到存在三个连通分量,Loom 将通过 connected-components
函数为我们计算这些分量。我们将在本章稍后看到如何实现一个算法来为我们自己计算这个:
(defn ex-8-13 []
(->> (load-edges "twitter/15053535.edges")
(apply loom/graph)
(alg/connected-components)))
;;[[30705196 58166411] [25521487 34173635 14230524 52831025 30973
;; 55137311 50201146 19039286 20978103 19562228 46186400
;;14838506 14596164 14927205] [270535212 334927007]]
如果一个有向图从每个节点到每个其他节点都有路径,那么它是强连通的。如果一个有向图仅将所有边视为无向边时,从每个节点到每个其他节点有路径,则它是弱连通的。
让我们将相同的图加载为有向图,以查看是否存在任何强连通分量:
(defn ex-8-14 []
(->> (load-edges "twitter/15053535.edges")
(apply loom/digraph)
(lio/view)))
这个示例生成了以下图像:
如前所述,存在三个弱连通分量。仅通过观察图形,确定有多少个强连通分量是相当困难的。Kosaraju 算法将计算图中强连通分量的数量。它由 Loom 实现为 alg/scc
函数:
(defn ex-8-15 []
(->> (load-edges "twitter/15053535.edges")
(apply loom/digraph)
(alg/scc)
(count)))
;; 13
Kosaraju 算法利用了一个有趣的性质,即转置图——即所有边都被反转的图——与输入图具有完全相同数量的连通分量。响应包含所有强连通分量(即使是仅包含一个节点的退化情况),作为序列向量。如果按长度降序排序,第一个分量将是最大的:
(defn ex-8-16 []
(->> (load-edges "twitter/15053535.edges")
(apply loom/digraph)
(alg/scc)
(sort-by count >)
(first)))
;;[14927205 14596164 14838506]
最大的强连通分量仅包含三个节点。
SCC 和网页的蝴蝶结结构
弱连通和强连通分量提供了一种了解有向图结构的有益方式。例如,关于互联网链接结构的研究表明,强连通分量可以非常大。
注意
以下数字引用的论文可以在线访问 www9.org/w9cdrom/160/160.html
。
尽管以下数字来源于 1999 年进行的一项研究,因此已经非常过时,但我们可以看到,在网络的中心有一个大型的强连通分量,由 5600 万页面组成。这意味着从强连通分量中的任何页面出发,只能通过跟随外向超链接来访问强连通分量中的任何其他页面。
4400 万页面链接到强连通分量(SCC)中,但没有从中链接出去,另外 4400 万页面从 SCC 中链接出去,但没有再链接回去。只有极少数链接完全绕过了 SCC(前面插图中的“管道”)。
整体图分析
让我们把注意力从之前处理的较小图转向更大的图,即由twitter_combined.txt
文件提供的关注者图。该文件包含超过 240 万个边,并将提供一个更有趣的样本来处理。
衡量一个图的最简单指标之一是它的密度。对于有向图,密度定义为边数 |E| 除以顶点数 |V| 乘以比自身少 1 的数。
对于一个连通图(每个顶点通过边与其他每个顶点相连),其密度为 1。相比之下,一个断开图(没有边的图)密度为 0。Loom 实现了图的密度计算函数 alg/density
。我们来计算一下更大 Twitter 图的密度:
(defn ex-8-17 []
(->> (load-edges "twitter_combined.txt")
(apply loom/digraph)
(alg/density)
(double)))
;; 2.675E-4
这看起来很稀疏,但请记住,值为 1 意味着每个账户都关注每个其他账户,而这显然不是社交网络的情况。一些账户可能有很多连接,而另一些账户则可能完全没有连接。
让我们看看边如何在节点之间分布。我们可以重用 Loom 的out-degree
函数来统计每个节点的外向边数,并使用以下代码绘制分布的直方图:
(defn ex-8-18 []
(let [graph (->> (load-edges "twitter_combined.txt")
(apply loom/digraph))
out-degrees (map #(loom/out-degree graph %)
(loom/nodes graph))]
(-> (c/histogram out-degrees :nbins 50
:x-label "Twitter Out Degrees")
(i/view))))
这将生成以下直方图:
出度分布看起来很像我们在第二章中首次遇到的指数分布。请注意,大多数账户的出度非常少,但有少数账户的出度超过了一千。
让我们也绘制一下入度的直方图。在 Twitter 中,入度对应于一个账户拥有的粉丝数量。
入度的分布更为极端:尾部延伸得比之前的直方图更远,第一根柱状比之前还要高。这对应于大多数账户粉丝非常少,但少数账户粉丝超过几千。
将之前的直方图与生成的随机图的度数分布进行对比。接下来,我们使用 Loom 的gen-rand
函数生成一个包含 10,000 个节点和 1,000,000 条边的随机图:
(defn ex-8-20 []
(let [graph (generate/gen-rand (loom/graph) 10000 1000000)
out-degrees (map #(loom/out-degree graph %)
(loom/nodes graph))]
(-> (c/histogram out-degrees :nbins 50
:x-label "Random out degrees")
(i/view))))
这段代码生成以下直方图:
随机图显示,连接一万个顶点和一百万条边的图的*均外度数大约为 200。度数的分布大致符合正态分布。显然,Twitter 图并不是通过随机过程生成的。
无标度网络
Twitter 的度数直方图呈现出幂律度分布的特点。与常规分布的随机生成图不同,Twitter 的直方图显示出少数顶点连接到大量的边。
注意
“无标度网络”这一术语是由圣母大学的研究人员在 1999 年提出,用以描述他们在万维网中观察到的结构。
在模拟人类互动的图中,我们经常会观察到一种连接的幂律现象。这也被称为Zipf定律,它揭示了所谓的“优先连接法则”,即受欢迎的顶点更容易发展出更多的连接。社交媒体网站是这一过程的典型例子,新的用户往往会关注已经受欢迎的用户。
在第二章中,推断部分,我们通过在对数-线性坐标轴上绘制数据并寻找直线来识别指数分布。我们可以通过在对数-对数坐标轴上寻找直线,最容易地确定幂律关系:
(defn ex-8-21 []
(let [graph (->> (load-edges "twitter_combined.txt")
(apply loom/digraph))
out-degrees (map #(loom/out-degree graph %)
(loom/nodes graph))
points (frequencies out-degrees)]
(-> (c/scatter-plot (keys points) (vals points))
(c/set-axis :x (c/log-axis :label "log(out-degree)"))
(c/set-axis :y (c/log-axis :label "log(frequency)"))
(i/view))))
这段代码返回以下图形:
尽管并非完全线性,但前面的图表足以表明 Twitter 图中存在幂律分布。如果我们可视化图中的节点与边之间的连接,无标度网络将因其典型的“聚集”形状而显现出来。受欢迎的顶点通常会围绕着一圈其他顶点。
将计算扩展到完整的 Twitter 合并数据集导致之前的示例运行速度显著变慢,尽管与许多社交网络相比,这个图形非常小。本章的其余部分将介绍一个基于 Spark 框架的图形库,叫做GraphX。GraphX 表达了本章已介绍的许多算法,但能够利用 Spark 的分布式计算模型来处理更大规模的图形。
使用 GraphX 进行分布式图计算
GraphX (spark.apache.org/graphx/
) 是一个为与 Spark 配合使用而设计的分布式图处理库。像我们在前一章中使用的 MLlib 库一样,GraphX 提供了一组基于 Spark RDD 的抽象。通过将图的顶点和边表示为 RDD,GraphX 能够以可扩展的方式处理非常大的图。
在前面的章节中,我们已经了解了如何使用 MapReduce 和 Hadoop 处理大规模数据集。Hadoop 是一种数据并行系统:数据集被分成若干组并行处理。Spark 同样是一个数据并行系统:RDD 被分布在集群中并行处理。
数据并行系统适合在数据呈现表格形式时进行数据处理扩展。图形可能具有复杂的内部结构,不适合作为表格来表示。虽然图形可以表示为边列表,但正如我们所见,以这种方式存储的图形在处理时可能涉及复杂的连接操作和过度的数据传输,因数据之间的紧密联系。
图数据的规模和重要性的增长推动了众多新的图并行系统的发展。通过限制可表达的计算类型并引入图的分区和分布技术,这些系统能够比一般的数据并行系统更高效地执行复杂的图算法,速度提高了几个数量级。
注意
有几个库将图并行计算引入 Hadoop,包括 Hama (hama.apache.org/
) 和 Giraph (giraph.apache.org/
)。
GraphX 库将图并行计算引入 Spark。使用 Spark 作为图处理引擎的一个优点是,其内存计算模型非常适合许多图算法的迭代特性。
该图展示了在节点可能相互连接的情况下并行处理图形的挑战。通过在图拓扑结构内处理数据,GraphX 避免了过多的数据移动和冗余。GraphX 通过引入弹性分布式图(Resilient Distributed Graph,简称 RDG)扩展了 Spark 的 RDD 抽象,并提供了一组用于查询和转换图形的函数,这些函数能够以结构化感知的方式处理图形。
使用 Glittering 创建 RDG
Spark 和 GraphX 是主要用 Scala 编写的库。在本章中,我们将使用 Clojure 库 Glittering (github.com/henrygarner/glittering
) 来与 GraphX 交互。就像 Sparkling 为 Spark 提供了一个简洁的 Clojure 包装器一样,Glittering 也为 GraphX 提供了一个简洁的 Clojure 包装器。
我们的第一个任务是创建一个图。图可以通过两种方式实例化:一种是提供两个 RDD 表示(一个包含边,另一个包含顶点),另一种是仅提供一个边的 RDD。如果仅提供边,则需要为每个节点提供一个默认值。接下来我们将看到如何实现这一点。
由于 GraphX 利用 Spark,每个作业都需要一个关联的 Spark 上下文。在上一章中,我们使用了 Sparkling 的spakling.conf/conf
默认配置。然而,在本章中,我们将使用 Glittering 提供的默认配置。Glittering 在 Sparkling 的默认设置基础上,添加了序列化和反序列化 GraphX 类型所需的配置。在以下代码中,我们将包含glittering.core
作为g
,并使用 Glittering 的图构造函数创建一个只有三条边的小图:
(defn ex-8-22 []
(spark/with-context sc (-> (g/conf)
(conf/master "local")
(conf/app-name "ch8"))
(let [vertices [[1 "A"] [2 "B"] [3 "C"]]
edges [(g/edge 1 2 0.5)
(g/edge 2 1 0.5)
(g/edge 3 1 1.0)]]
(g/graph (spark/parallelize sc vertices)
(spark/parallelize sc edges)))))
;; #<GraphImpl org.apache.spark.graphx.impl.GraphImpl@adb2324>
结果是一个 GraphX 图对象。请注意,边是通过g/edges
的 RDD 提供的:g/edge
函数将根据源 ID、目标 ID 和可选的边属性创建一个边类型。边的属性可以是 Spark 可以序列化的任何对象。注意,顶点也可以有属性(例如前面示例中的"A"、"B"和"C")。
构造图的另一种方法是使用g/graph-from-edges
构造函数。这将仅根据边的 RDD 返回一个图。Twitter 数据以边列表格式提供,因此我们将使用此函数加载它。在接下来的代码中,我们将加载完整的twitter_combined.txt
文本文件,并通过映射文件中的每一行来创建一个边列表。从每一行,我们将创建一个权重为 1.0 的边:
(defn line->edge [line]
(let [[from to] (map to-long (str/split line #" "))]
(g/edge from to 1.0)))
(defn load-edgelist [sc path]
(let [edges (->> (spark/text-file sc path)
(spark/map line->edge))]
(g/graph-from-edges edges 1.0)))
(defn ex-8-23 []
(spark/with-context sc (-> (g/conf)
(conf/master "local")
(conf/app-name "ch8"))
(load-edgelist sc "data/twitter_combined.txt")))
;;#<GraphImpl org.apache.spark.graphx.impl.GraphImpl@c63044d>
graph-from-edges
函数的第二个参数是每个顶点属性的默认值:在边列表中无法提供顶点属性。
使用三角计数衡量图的密度
GraphX 自带了一些内置的图算法,Glittering 将其提供在glittering.algorithms
命名空间中。在详细介绍 Glittering 的 API 之前,让我们在 Twitter 关注图上运行其中一个算法。我们将展示如何使用 Glittering 创建一个简单的图处理作业,然后展示如何使用更多 Glittering 的 API,结合 GraphX 的图并行原语,自己实现算法。
三角计数是一种算法,用来衡量图中每个节点附*的密度。它在原理上类似于计算度数,但还考虑了邻居之间的连接情况。我们可以用这个非常简单的图来形象化这个过程:
在这个例子中,我们可以看到顶点 A、B 和 C 都参与了一个三角形,而顶点 D 则没有参与。B 和 C 都跟随 A,但 C 也跟随 B。在社交网络分析的背景下,三角形计数是衡量朋友的朋友是否相互认识的指标。在紧密的社区中,我们预计三角形的数量会很高。
三角形计数已经在 GraphX 中实现,并可以作为 glittering.algorithms
命名空间中的 triangle-count
函数访问。在使用这个特定的算法之前,GraphX 要求我们做两件事:
-
将边指向“标准”方向。
-
确保图被划分。
这两个步骤都是 GraphX 中三角形计数实现的副产品。GraphX 允许两个顶点之间有多个边,但三角形计数只计算不同的边。前两个步骤确保 GraphX 能够在执行算法之前高效地计算出不同的边。
边的标准方向始终是从较小的节点 ID 指向较大的节点 ID。我们可以通过确保在首次构建边 RDD 时所有边都按照这个方向创建来实现这一点:
(defn line->canonical-edge [line]
(let [[from to] (sort (map to-long (str/split line #" ")))]
(glitter/edge from to 1.0)))
(defn load-canonical-edgelist [sc path]
(let [edges (->> (spark/text-file sc path)
(spark/map line->canonical-edge))]
(glitter/graph-from-edges edges 1.0)))
通过在创建边之前先对 from
和 to
ID 进行排序,我们确保 from
ID 总是小于 to
ID。这是使去重更高效的第一步。第二步是为图选择一个划分策略。下一节将描述我们的选择。
GraphX 划分策略
GraphX 是为分布式计算而构建的,因此必须将图划分到多个机器上。通常,在划分图时,您可以采取两种方法:'边切割'和'顶点切割'方法。每种方法都有不同的权衡。
边切割策略可能看起来是划分图的最“自然”方式。通过沿着边划分图,它确保每个顶点都被分配到一个分区,并通过灰色阴影表示出来。然而,这对于表示跨分区的边会产生问题。任何沿边进行的计算都必然需要从一个分区发送到另一个分区,而最小化网络通信是实现高效图算法的关键。
GraphX 实现了“顶点切割”方法,确保边被分配到分区,并且顶点可以跨分区共享。这似乎只是将网络通信转移到图的另一个部分——从边转移到顶点——但是 GraphX 提供了多种策略,允许我们确保顶点以最适合我们想要应用的算法的方式进行划分。
Glittering 提供了partition-by
函数,该函数接受一个表示划分图策略的关键字。接受的值有:edge-partition-1d
、:edge-partition-2d
、:canonical-random-vertex-cut
和:random-vertex-cut
。
你选择使用哪个划分策略取决于图的结构和你将应用的算法。:edge-partition-1d
策略确保所有具有相同源的边被划分到一起。这意味着按源节点聚合边的操作(例如,计数出边)将所有所需的数据都集中在单台机器上。虽然这样可以最小化网络流量,但也意味着在幂律图中,少数几个分区可能会收到整体边数的相当大一部分。
:random-vertex-cut
划分策略根据源节点和目标节点将图形划分为边。这有助于创建更*衡的分区,但以运行时性能为代价,因为单一的源或目标节点可能会分布到集群中的多个机器上。即使是连接相同节点对的边,也可能根据边的方向分布在两台机器上。为了忽略方向而对边进行分组,我们可以使用:canonical-random-vertex-cut
。
最后,:edge-partition-2d
通过源节点和目标节点使用更复杂的划分策略来划分边。与:canonical-random-vertex-cut
一样,共享源节点和目标节点的节点将被一起划分。此外,该策略对每个节点可能分布的分区数量设置了上限。当一个算法汇总共享源节点和目标节点的边的信息,以及源或目标节点的独立信息时,这可能是最有效的策略。
运行内建的三角形计数算法
我们已经看过如何以标准方向加载边。下一步是选择一个划分策略,我们将选择:random-vertex-cut
。以下示例展示了加载和划分图形的完整过程,执行三角形计数并使用 Incanter 可视化结果:
(defn ex-8-24 []
(spark/with-context sc (-> (g/conf)
(conf/master "local")
(conf/app-name "ch8"))
(let [triangles (->> (load-canonical-edgelist
sc "data/twitter_combined.txt")
(g/partition-by :random-vertex-cut)
(ga/triangle-count)
(g/vertices)
(to-java-pair-rdd)
(spark/values)
(spark/collect)
(into []))
data (frequencies triangles)]
(-> (c/scatter-plot (keys data) (vals data))
(c/set-axis :x (c/log-axis :label "# Triangles"))
(c/set-axis :y (c/log-axis :label "# Vertices"))
(i/view)))))
triangle-count
的输出是一个新图,其中每个顶点的属性是该顶点参与的三角形数量。顶点的 ID 保持不变。我们只关心三角形计数本身——返回图的顶点属性——因此我们从顶点中提取values
。spark/collect
函数将所有值收集到一个 Clojure 序列中,因此我们不希望在非常大的图上执行此操作。
在收集到三角形计数后,我们计算每个计数的频率,并使用 Incanter 在对数-对数散点图上可视化结果。输出如下所示:
我们再次看到了幂律分布的影响。少数节点连接了大量的三角形。
运行内置算法让我们了解了如何创建和操作图,但 GraphX 的真正力量在于它如何让我们以高效的方式自己表达这种计算。在下一部分,我们将看到如何使用底层函数实现三角形计数。
用 Glittering 实现三角形计数
计数图中三角形的方式有很多种,但 GraphX 以以下方式实现算法:
-
计算每个顶点的邻居集合。
-
对于每条边,计算两端顶点的交点。
-
将交集的计数发送到两个顶点。
-
计算每个顶点的计数总和。
-
除以二,因为每个三角形被计算了两次。
以下图示展示了我们简单图中只包含一个三角形的步骤:
算法忽略边的方向,正如前面提到的,它要求任意两个节点之间的边是唯一的。因此,我们将继续在上一部分定义的规范边的分区图上工作。
执行三角形计数的完整代码并不长,所以下文将完整展示。它代表了我们在本章其余部分将要介绍的大多数算法,因此,在展示代码之后,我们将逐步讲解每一个步骤:
(defn triangle-m [{:keys [src-id src-attr dst-id dst-attr]}]
(let [c (count (set/intersection src-attr dst-attr))]
{:src c :dst c}))
(defn triangle-count [graph]
(let [graph (->> (g/partition-by :random-vertex-cut graph)
(g/group-edges (fn [a b] a)))
adjacent (->> (g/collect-neighbor-ids :either graph)
(to-java-pair-rdd)
(spark/map-values set))
graph (g/outer-join-vertices
(fn [vid attr adj] adj) adjacent graph)
counters (g/aggregate-messages triangle-m + graph)]
(->> (g/outer-join-vertices (fn [vid vattr counter]
(/ counter 2))
counters graph)
(g/vertices))))
为了使算法正常工作,输入图必须具有不同的边。一旦规范图被划分,我们通过在图上调用(g/group-edges (fn [a b] a) graph)
来确保边是唯一的。group-edges
函数类似于reduce
,它对共享相同起始节点和结束节点的边集合进行归约。我们仅选择保留第一条边。边的属性在三角形计数中并不起作用,重要的是存在一条边。
步骤一——收集邻居 ID
在步骤一,我们想要收集每个顶点的邻居 ID。Glittering 提供了作为g/collect-neighbor-ids
函数的这个操作。我们可以选择只收集传入或传出的边,分别使用:in
或:out
,或者收集任一方向的边,使用:either
。
g/collect-neighbor-ids
函数返回一个对 RDD,键是相关的顶点 ID,值是邻居 ID 的序列。像上一章的 MLlib 一样,RDD 不是 Sparkling 所期望的 JavaRDD
类,因此我们必须进行相应的转换。完成转换后,将邻居 ID 序列转换为集合就像在对 RDD 中的每个值调用set
一样简单。步骤一的结果是一个包含节点 ID 和邻居 ID 集合的 PairRDD,因此我们已经将图转换为一系列以 adjacent
为值存储的集合。
注意
这种图表示方式作为一组连接顶点的集合,通常称为邻接列表。与边列表一起,它是表示图的两种主要方式之一。
第二步要求我们给图的边分配值,因此我们需要保留图的结构。我们使用 g/outer-join-vertices
函数将 adjacent
和原始图合并。给定一个图和一个按顶点 ID 索引的对 RDD,outer-join-vertices
允许我们提供一个函数,其返回值将被分配为图中每个顶点的属性。该函数接收三个参数:顶点 ID、当前顶点属性和与顶点 ID 在被外连接到图的对 RDD 中关联的值。在之前的代码中,我们返回了邻接顶点集作为新的顶点属性。
第二、三、四步 – 聚合消息
接下来的几个步骤由一个函数g/aggregate-messages
处理,这是 GraphX 图并行实现的核心函数。它需要两个参数:一个消息发送函数和一个消息合并函数。通过它们的协同工作,这两个函数类似于为图并行计算中的顶点中心视图适配的 map 和 reduce。
发送消息函数负责沿边发送消息。该函数每次为每条边调用一次,但可以向源顶点或目标顶点发送多个消息。该函数的输入是一个三元组(包含两个连接顶点的边),并返回一组消息。消息是一个键值对,其中键是 :src
或 :dst
之一,值是要发送的消息。在前面的示例中,这是通过使用 :src
和 :dst
键实现的映射。
合并消息函数负责合并特定顶点的所有消息。在之前的代码中,每条消息是一个数字,因此合并函数有一组数字需要合并。我们可以简单地通过将 +
作为合并函数来实现这一点。
第五步 – 除以计数
三角形计数的最终步骤是将我们为每个顶点 ID 计算出的计数除以二,因为每个三角形被计算了两次。在之前的代码中,我们在更新顶点属性时,同时使用 outer-join-vertices
来执行此操作,更新的顶点属性为三角形计数。
运行自定义三角形计数算法
在前面所有步骤都完成后,我们可以使用 Glittering 运行自定义的三角形计数算法。我们先在章节开头的一个 Twitter 关注图上运行它,看看我们得到的结果:
(defn ex-8-25 []
(spark/with-context sc (-> (g/conf)
(conf/master "local")
(conf/app-name "triangle-count"))
(->> (load-canonical-edgelist
sc "data/twitter/396721965.edges")
(triangle-count)
(spark/collect)
(into []))))
;; #sparkling/tuple [21938120 1] #sparkling/tuple [31477674 3]
;; #sparkling/tuple [32122637 0] ...]
结果是一个元组序列,顶点 ID 作为键,连接的三角形数量作为值。
如果我们想知道整个 Twitter 数据集中有多少个三角形,我们可以从结果图中提取值(这些值),将它们加起来,然后除以三。我们现在就来做这个:
(defn ex-8-26 []
(spark/with-context sc (-> (g/conf)
(conf/master "local")
(conf/app-name "triangle-count"))
(let [triangles (->> (load-canonical-edgelist
sc "data/twitter_combined.txt")
(triangle-count)
(to-java-pair-rdd)
(spark/values)
(spark/reduce +))]
(/ triangles 3))))
该算法运行不应该太长时间。我们自定义的三角形计数代码足够高效,可以在整个合并的 Twitter 数据集上运行。
如果 aggregate-messages
类似于 MapReduce 编程的单步操作,我们通常会反复执行它。许多图算法会希望运行到收敛。实际上,GraphX 提供了一种替代函数,我们在这种情况下可以使用它,叫做 Pregel API。我们将在下一节中详细讨论它。
Pregel API
Pregel API 是 GraphX 表达自定义、迭代、图并行计算的主要抽象。它以谷歌内部运行大规模图处理的系统命名,谷歌在 2010 年发布了关于该系统的论文。你可能还记得,这也是建设在 Königsberg 镇上的那条河流。
谷歌的 Pregel 论文推广了“像顶点一样思考”的图并行计算方法。Pregel 的模型本质上使用了图中顶点之间的消息传递,组织成一系列称为 超级步骤 的步骤。在每个超级步骤开始时,Pregel 在每个顶点上运行用户指定的函数,传递在前一个超级步骤中发送给它的所有消息。顶点函数有机会处理每一条消息,并将消息发送到其他顶点。顶点还可以“投票终止”计算,当所有顶点都投票终止时,计算将结束。
Glittering 实现的 pregel
函数采用了与图处理非常相似的方法。主要的区别是顶点不会投票终止:计算将在没有消息发送或超过指定迭代次数时终止。
虽然上一节中介绍的 aggregate-messages 函数使用了两个互补的函数来表达其意图,但 pregel
函数使用了三个相关的函数,迭代地应用于图算法。前两个是我们之前遇到的消息函数和消息合并器,第三个是“顶点程序”:一个处理每个顶点接收消息的函数。该函数的返回值将作为下一个超级步骤的顶点属性。
让我们通过实现本章已讲解的一个算法:连通组件,来实际看看pregel
函数是如何工作的。
使用 Pregel API 处理连通组件
连通组件可以用以下方式表示为迭代算法:
-
初始化所有顶点属性为顶点 ID。
-
对于每条边,确定源顶点或目标顶点属性中的最小值。
-
沿着每条边,将两个属性中较小的一个发送到对面的顶点。
-
对于每个顶点,将其属性更新为传入消息中的最小值。
-
重复直到节点属性不再变化。
如之前所示,我们可以在一个简单的四节点图上可视化这个过程。
我们可以看到,经过六个步骤,图已收敛到一个状态,其中所有节点的属性都为最低的连通顶点 ID。由于消息仅沿着边传递,任何没有共享边的节点将会收敛到不同的值。因此,算法收敛后,所有共享相同属性的顶点将属于同一连通分量。首先让我们看看完成的代码,然后我们会立即按步骤讲解代码:
(defn connected-component-m [{:keys [src-attr dst-attr]}]
(cond
(< src-attr dst-attr) {:dst src-attr}
(> src-attr dst-attr) {:src dst-attr}))
(defn connected-components [graph]
(->> (glitter/map-vertices (fn [id attr] id) graph)
(p/pregel {:vertex-fn (fn [id attr msg]
(min attr msg))
:message-fn connected-component-m
:combiner min})))
使用g/pregel
函数是实现迭代连通分量算法所需要的全部。
第一步 – 映射顶点
初始化所有顶点属性为顶点 ID 的操作是由g/map-vertices
函数在pregel
函数之外处理的。我们传递给它一个包含两个参数的函数,分别是顶点 ID 和顶点属性,它返回需要分配为顶点属性的顶点 ID。
第二步和第三步 – 消息函数
Glittering 的pregel
函数期望接收一个至少指定了三个函数的映射:消息函数、合并器函数和顶点函数。我们稍后将更详细地讨论最后一个函数。然而,第一个函数负责第二步和第三步:对于每条边,确定哪个连接节点具有较低的属性,并将此值发送到对方节点。
我们在本章早些时候介绍了消息函数以及自定义的三角形计数函数。此函数接收边作为映射,并返回描述将要发送的消息的映射。这一次,仅发送一条消息:如果源属性较低,则将src-attr
属性发送到目标节点;如果目标属性较低,则将dst-attr
属性发送到源节点。
合并器函数聚合所有传入顶点的消息。对于连通分量的合并器函数,简单地使用min
函数:我们只关心发送给每个顶点的最小值。
第四步 – 更新属性
在第四步中,每个顶点将其属性更新为当前属性和所有接收到的消息中的最小值。如果任何传入消息低于当前属性,它将更新其属性为最小值。此步骤由顶点程序处理,这是 Pregel 的三个共生函数中的第三个。
连通分量的顶点函数也很简单:对于每个顶点,我们希望返回当前顶点属性和所有接收到的消息中的最低值(由上一步骤的合并器函数确定)。返回值将作为下一次超级步骤中的顶点属性使用。
第五步 – 迭代直到收敛
第五步是通过 pregel
函数免费获得的。我们没有指定最大迭代次数,因此刚才描述的三个函数将会重复运行,直到没有更多消息需要发送。因此,(出于效率考虑)重要的是我们的消息函数只在必要时发送消息。这就是为什么我们之前消息函数中的 cond
值确保如果源和目标属性已经相等,我们就不会发送消息。
运行连接组件
在实现了之前的连接组件函数后,我们将在以下示例中使用它:
(defn ex-8-27 []
(spark/with-context sc (-> (g/conf)
(conf/master "local")
(conf/app-name "cljds.ch8"))
(->> (load-edgelist sc "data/twitter/396721965.edges")
(connected-components)
(g/vertices)
(spark/collect)
(into []))))
;; [#sparkling/tuple [163629705 21938120] #sparkling/tuple
;; [88491375 21938120] #sparkling/tuple [142960504 21938120] ...
通过将图转换回 RDD,我们可以以数据并行的方式进行分析。例如,我们可以通过计算共享相同属性的节点数来确定所有连接组件的大小。
计算最大连接组件的大小
在下一个示例中,我们将使用相同的连接组件函数,但计算每个连接组件的大小。我们将通过 Sparkling 的 count-by-value
函数来实现:
(defn ex-8-28 []
(spark/with-context sc (-> (g/conf)
(conf/master "local")
(conf/app-name "ch8"))
(->> (load-canonical-edgelist
sc "data/twitter_combined.txt")
(connected-components)
(g/vertices)
(to-java-pair-rdd)
(spark/values)
(spark/count-by-value)
(into []))))
;; [[12 81306]]
像前面示例中那样的代码是使用 GraphX 和 Glittering 的巨大好处之一。我们可以将表示为边列表的*面数据转换成图结构,以执行迭代图算法,然后将结果转换回*面结构以计算聚合:所有操作都在一个管道中完成。
示例的响应表明,所有的顶点——81,306 个——都在一个大的连接组件中。这表明图中的每个人都与其他人相连,无论是朋友还是关注者。
虽然知道没有孤立的用户群体很有用,但理解用户在连接组件中的组织方式会更有趣。如果某些用户群体之间的连接更加密集,那么我们可以认为这些用户形成了一个社区。
使用标签传播检测社区
社区可以非正式地定义为一组顶点,这些顶点之间的连接比与社区外部顶点的连接要强得多。
注意
如果每个顶点都与社区中的其他每个顶点相连,那么我们将称这个社区为团体(clique)。
因此,社区对应于图中的密度增加。我们可以将 Twitter 网络中的社区视为关注者群体,这些群体也倾向于关注彼此的关注者。较小的社区可能对应于友谊小组,而较大的社区更可能对应于共享兴趣小组。
社区检测是一种通用技术,有许多算法能够识别社区。根据算法的不同,社区可能会重叠,这样一个用户可能会关联到多个社区。我们接下来要看的算法叫做标签传播,它将每个用户分配到最多一个社区。
标签传播可以通过以下步骤进行迭代实现:
-
将所有顶点属性初始化为顶点 ID。
-
对于每条边,发送源顶点和目标顶点的属性到对方节点。
-
对于每个顶点,计算每个传入属性的频率。
-
对于每个顶点,更新属性为传入属性中最频繁的那个。
-
重复进行,直到收敛或达到最大迭代次数。
算法的步骤接下来展示在一个包含两个社区的图中。每个社区也是一个团体,但这并不是标签传播有效的必要条件。
使用pregel
函数进行标签传播的代码如下:
(defn label-propagation-v [id attr msg]
(key (apply max-key val msg)))
(defn label-propagation-m [{:keys [src-attr dst-attr]}]
{:src {dst-attr 1}
:dst {src-attr 1}})
(defn label-propagation [graph]
(->> (glitter/map-vertices (fn [vid attr] vid) graph)
(p/pregel {:message-fn label-propagation-m
:combiner (partial merge-with +)
:vertex-fn label-propagation-v
:max-iterations 10})))
如前所述,让我们一步步解析代码。
第一步 – 映射顶点
标签传播的第一步与我们之前定义的连通组件算法的第一步相同。我们使用g/map-vertices
函数来更新每个顶点的属性,使其等于顶点 ID。
第二步 – 发送顶点属性
在第二步中,我们沿每条边发送对方顶点的属性。第三步将要求我们统计所有传入属性中最频繁的那个,因此每条消息都是一个将属性映射到值“1”的映射。
第三步 – 聚合值
合并函数接收顶点的所有消息,并生成一个聚合值。由于消息是属性值到数字“1”的映射,我们可以使用 Clojure 的merge-with
函数将消息合并,并使用+
运算符。结果将是一个属性到频率的映射。
第四步 – 顶点函数
第四步由顶点函数处理。给定所有传入属性的频率计数,我们需要选择最频繁的一个。(apply max-key val msg)
表达式返回与最大值(即最高频率)关联的键/值对。我们将此值传递给key
,以返回与该值关联的属性。
第五步 – 设置最大迭代次数
与连通组件算法一样,当还有消息需要发送时,pregel
函数的默认行为是迭代。不同于连通组件算法的是,在之前的message
函数中我们没有条件语句。为了避免无限循环,我们在pregel
的选项映射中传递了:max-iterations
的值为 10。
运行标签传播
以下示例利用之前的代码在完整的 Twitter 数据集上执行标签传播。我们使用 Sparkling 的count-by-value
函数计算每个社区的大小,并计算计数的频率。然后,使用 Incanter 将结果的直方图可视化在对数-对数散点图上,以显示社区大小的分布:
(defn ex-8-29 []
(spark/with-context sc (-> (g/conf)
(conf/master "local")
(conf/app-name "ch8"))
(let [xs (->> (load-canonical-edgelist
sc "data/twitter_combined.txt")
(label-propagation)
(g/vertices)
(to-java-pair-rdd)
(spark/values)
(spark/count-by-value)
(vals)
(frequencies))]
(-> (c/scatter-plot (keys xs) (vals xs))
(c/set-axis :x (c/log-axis :label "Community Size"))
(c/set-axis :y (c/log-axis :label "# Communities"))
(i/view)))))
此代码生成以下图表:
正如我们所预期的那样,社区规模的分布也是一个幂律分布:小型社区远比大型社区更为常见。最大型的社区大约有 10,000 名成员,而最小型的社区仅包含一个成员。我们开始逐步揭示 Twitter 图的结构:我们可以感知用户是如何分组的,并假设较大的社区可能代表那些由共同兴趣联系在一起的群体。
在本章的最后几页,让我们看看是否能够确定这些最大社区的共同点。有许多方法可以实现这一目标。如果我们可以访问推文本身,我们可以进行文本分析,就像我们在第六章中进行的聚类分析那样,看看这些群体中是否有特定的词汇或语言更常使用。
本章主要讲解网络分析,因此我们只需使用图的结构来识别每个社区中最具影响力的账户。最具影响力的十大账户列表可能会为我们提供一些关于哪些内容在粉丝中产生共鸣的线索。
使用 PageRank 测量社区影响力
测量社区内影响力的一种简单方法是计算特定顶点的入边数量。在 Twitter 上,这相当于一个拥有大量粉丝的账户。这些账户代表了网络中最“受欢迎”的用户。
仅计算入边数量是一种简单的影响力衡量方法,因为它将所有入边视为*等的。在社交图中,情况通常并非如此,因为某些粉丝本身就是受欢迎的账户,因此他们的关注比那些没有粉丝的用户更为重要。
注意
PageRank 由拉里·佩奇(Larry Page)和谢尔盖·布林(Sergey Brin)于 1996 年在斯坦福大学开发,作为最终形成 Google 的研究项目的一部分。PageRank 通过计算指向页面的链接数量和质量来估算该网站的重要性。
因此,账户的重要性基于两个因素:粉丝的数量以及每个粉丝的重要性。每个粉丝的重要性按相同方式计算。PageRank 因此具有递归定义:看起来我们必须先计算粉丝的重要性,才能计算账户的重要性,以此类推。
流量公式
幸运的是,PageRank 可以通过迭代的方式计算。首先,我们初始化所有顶点的权重相同。可以设置权重为 1;这种情况下,所有权重的总和等于顶点数 N。或者,也可以将权重设置为 ;在这种情况下,所有权重的总和等于 1。虽然这不改变基本的算法,但后者通常更受欢迎,因为它意味着 PageRank 的结果可以被解释为概率。我们将实现前者。
这个初始权重是每个账户在算法开始时的 PageRank r。在第一次迭代中,每个账户将自己排名的一部分*均分配给它所关注的每个页面。经过这一步,账户 j 和 rj 的排名被定义为所有传入排名的总和。我们可以通过以下方程表达这一点:
这里,r[i] 是关注者的排名,ni 是他们关注的账户数。账户 j 会从所有粉丝那里接收到一个比例的排名,。
如果 PageRank 就只有这些内容,那么没有粉丝的账户将会直接拥有零排名,并且在每次迭代中,最受欢迎的页面将会变得越来越受欢迎。因此,PageRank 还包括一个阻尼因子。这个因子确保即使是最不受欢迎的账户也能保留一定的权重,从而使得算法能够收敛到稳定值。这个可以通过修改前面的方程来表示:
这里,d 是阻尼因子。一个常用的阻尼因子是 85%。
以下图展示了阻尼因子对一组十一个账户的影响:
如果没有阻尼因子,所有的权重最终都会积累到账户 A、B 和 C 上。加入阻尼因子后,即使是没有关注任何人的小账户也会继续收到整体权重的一个小比例。尽管账户 E 的粉丝更多,账户 C 的排名却更高,因为它被高排名账户所关注。
使用 Glittering 实现 PageRank
我们通过以下示例代码使用 pregel
函数实现 PageRank。代码的结构你应该已经很熟悉了,尽管我们将使用一些新的 Glittering 函数:
(def damping-factor 0.85)
(defn page-rank-v [id prev msgsum]
(let [[rank delta] prev
new-rank (+ rank (* damping-factor msgsum))]
[new-rank (- new-rank rank)]))
(defn page-rank-m [{:keys [src-attr attr]}]
(let [delta (second src-attr)]
(when (> delta 0.1)
{:dst (* delta attr)})))
(defn page-rank [graph]
(->> (glitter/outer-join-vertices (fn [id attr deg] (or deg 0))
(glitter/out-degrees graph)
graph)
(glitter/map-triplets (fn [edge]
(/ 1.0 (glitter/src-attr edge))))
(glitter/map-vertices (fn [id attr] (vector 0 0)))
(p/pregel {:initial-message (/ (- 1 damping-factor)
damping-factor)
:direction :out
:vertex-fn page-rank-v
:message-fn page-rank-m
:combiner +
:max-iterations 20})
(glitter/map-vertices (fn [id attr] (first attr)))))
我们以通常的方式开始,使用 outer-join-vertices
将每个节点的 out-degrees
与其自身连接。经过这一步,所有节点的属性都等于其出链数。然后,我们使用 map-triplets
将所有的 edge
属性设置为其源顶点属性的倒数。最终效果是每个顶点的排名在它的所有出边之间*均分配。
在此初始化步骤之后,我们使用 map-edges
将每个节点的属性设置为默认值:一个包含两个零的向量。这个向量包含当前的 PageRank 和该迭代的排名与前一迭代排名之间的差异。根据差异的大小,我们的 message
函数可以决定是否继续迭代。
按最高影响力排序
在对标签传播确定的社区运行 PageRank 之前,我们将实现一个实用功能,列出排名前 10 的账户,并按排名降序排列。top-n-by-pagerank
函数将允许我们仅显示排名最高的账户:
(defn top-n-by-pagerank [n graph]
(->> (page-rank graph)
(g/vertices)
(to-java-pair-rdd)
(spark/map-to-pair
(s-de/key-value-fn
(fn [k v]
(spark/tuple v k))))
(spark/sort-by-key false)
(spark/take n)
(into [])))
再次强调,我们可以轻松地在图和表格表示之间转换,从而实现这种数据操作,这正是使用 Glittering 和 Sparkling 进行图分析的主要优势之一。
最后,最好有一个返回出现在第一行中最频繁出现的节点属性的函数:
(defn most-frequent-attributes [graph]
(->> (g/vertices graph)
(to-java-pair-rdd)
(spark/values)
(spark/count-by-value)
(sort-by second >)
(map first)))
根据标签传播的输出,这个函数将按大小递减的顺序返回社区 ID 的序列。
运行 PageRank 确定社区影响者
最后,我们可以将之前的所有代码整合起来,以识别通过标签传播算法确定的社区的最具共鸣的兴趣。与我们迄今为止在 Glittering 中实现的其他算法不同,我们在这里发送消息的方向是跟随方向,而不是标准方向。因此,在下一个示例中,我们将使用 load-edgelist
加载图,它保留了跟随方向:
(defn ex-8-30 []
(spark/with-context sc (-> (g/conf)
(conf/master "local")
(conf/app-name "ch8"))
(let [communities (->> (load-edgelist
sc "data/twitter_combined.txt")
(label-propagation))
by-popularity (most-frequent-attributes 2 communities)]
(doseq [community (take 10 by-popularity)]
(println
(pagerank-for-community community communities))))))
这段代码运行会花费一些时间,但最终会返回一个序列,列出每个十个最受欢迎社区图中的最重要节点,如下例所示:
;;[#sparkling/tuple [132.8254006818738 115485051]
;;#sparkling/tuple [62.13049747055527 62581962]
;;#sparkling/tuple [49.80716333905785 65357070]
;;#sparkling/tuple [46.248688749879875 90420314] ...]
每个元组的第一个元素是我们为该顶点计算的 PageRank,第二个元素是节点 ID。Twitter 的顶点 ID 对应于 Twitter 自己的 ID。由于账户未经过匿名化处理,我们可以查找它们对应的 Twitter 账户。
注意
在写作时,我们可以通过 Twitter 的 Intent API 查找 Twitter 账户 ID,API 地址为 https://twitter.com/intent/user?user_id={$USER_ID}
。用 Twitter 的数字 ID 替换 {$USER_ID}
,将返回基本的个人资料信息。
社区一中排名最高的 PageRank 账户包括美国漫画家和脱口秀主持人 Conan O'Brien、巴拉克·奥巴马、Felicia Day 和 Neil Patrick Harris。我们可以大致将这些人归类为美国名人。不可否认的是,在 Twitter 上,最大的社区聚集了那些拥有最大账户和最广泛影响力的人。
接着向下看,第二大社区的顶级影响者包括乐队 Paramore、成员 Hayley 和 Taylor,以及 Lady Gaga。显然,这个社区有一套非常特定的音乐兴趣。
第三和第四个社区似乎都具有较强的游戏偏好,主要特点是 X-Box、PlayStation、Steam,以及 Minecraft 的创始人 Markus Persson,都是它们的主要影响力人物。
请记住,我们已经确定整个图是一个连通分量的一部分,因此我们并没有查看断开的用户集合。通过结合标签传播和 PageRank,我们能够确定具有相关兴趣的 Twitter 用户群体。
总结
在这一章中,我们学习了图形:一种用于建模各种现象的有用抽象。我们首先使用 Clojure 库 Loom 来可视化和遍历 Twitter 粉丝的一个小图形。我们了解了两种不同的图遍历方法,深度优先搜索和广度优先搜索,并研究了改变边权重对 Dijkstra 算法和 Prim 算法发现路径的影响。我们还观察了整个图的密度,并绘制了度分布,以观察随机图和无尺度图之间的差异。
我们介绍了 GraphX 和 Clojure 库 Glittering,作为一种使用 Spark 以可扩展的方式处理大规模图形的方法。除了提供几个内置的图算法外,Glittering 还暴露了 GraphX 的 Pregel API:一组三种共生函数,用于以顶点为中心的方式表达图算法。我们展示了这种计算模型如何用于表示三角形计数、连通分量、标签传播,最后是 PageRank 算法,并将标签传播和 PageRank 步骤链在一起,以确定一组 Twitter 社区的顶级影响力人物。
这是我们使用并行计算技术的最后一章。在下一章中,我们将重点讨论本地数据处理,但我们仍然会延续递归分析的主题。我们将介绍处理时间序列数据的方法——按时间顺序排列的观察序列——并展示如何利用递归函数来生成预测。
第九章 时间序列
“再次经过的时间。” | ||
---|---|---|
--卡罗琳·基恩,《老钟的秘密》 |
在之前的几章中,我们看到如何应用迭代算法来解决复杂方程。我们首先在梯度下降法中遇到这一点——包括批量和随机梯度下降——但最*我们在使用图并行计算模型进行图社区检测时也看到了这一点。
本章讨论的是时间序列数据。时间序列是指按测量时间排列的某一数量的定期观测数据系列。为了使本章中的许多技术能够工作,我们要求连续观测之间的间隔是相等的。测量间隔可以是销售数据的月度、降雨量或股市波动的日度,或者高流量网站的访问量以分钟为单位。
为了能够预测时间序列的未来值,我们需要未来的值在某种程度上依赖于之前的值。因此,本章也涉及到递归:我们如何构建一个序列,其中每个新值都是前一个值的函数。通过将实际时间序列建模为一个新值以这种方式生成的过程,我们希望能够将序列向前推演,并做出预测。
不过,在我们开始讨论递归之前,我们将学习如何利用已经接触过的一种技术——线性回归——来拟合时间序列数据的曲线。
关于数据
本章将使用两个 Incanter 预装的数据集:Longley 数据集,包含了 1947 年到 1962 年间美国七个经济变量的数据,以及Airline 数据集,包含了 1949 年 1 月到 1960 年 12 月的每月航空乘客总数数据。
注意
你可以从github.com/clojuredatascience/ch9-time-series
下载本章的源代码。
Airline 数据集是我们在本章中将花费大部分时间的部分,但首先让我们看看 Longley 数据集。它包含的列包括国内生产总值(GDP)、就业与失业人口数量、人口和军队规模。由于许多预测变量本身是相关的,这使得它成为分析多重共线性的经典数据集。幸运的是,这不会影响我们的分析,因为我们一次只使用一个预测变量。
加载 Longley 数据
由于 Incanter 将 Longley 数据集作为其示例数据集库的一部分,因此加载数据只需调用incanter.datasets/get-dataset
并将:longley
作为唯一参数。一旦加载完毕,我们可以通过incanter.core/view
来查看数据集:
(defn ex-9-1 []
(-> (d/get-dataset :longley)
(i/view)))
数据应大致如下所示:
这些数据最初由国家标准与技术研究院(NIST)发布,作为统计参考数据集,列的描述可以在他们的网站上找到:www.itl.nist.gov/div898/strd/lls/data/LINKS/i-Longley.shtml
。我们将考虑最后三列:x4:武装部队的规模,x5:14 岁及以上的“非机构”人口,以及x6:年份。
首先,让我们看看人口随时间的变化:
(defn ex-9-2 []
(let [data (d/get-dataset :longley)]
(-> (c/scatter-plot (i/$ :x6 data)
(i/$ :x5 data)
:x-label "Year"
:y-label "Population")
(i/view))))
前面的代码生成了以下图表:
年份与人口的图表显示出一种非常明显的*似线性关系。轻微的曲线表明,随着人口增加,人口增长率也在上升。
注意
回想一下第三章中的吉布拉特法则,相关性,公司增长率与其规模成正比。在分析适用吉布拉特法则的人口时,常常会看到类似前面图表的增长曲线:增长率会随着时间的推移而增加。
我们已经看到如何使用 Incanter 的线性模型拟合直线。或许令人惊讶的是,linear-model
函数同样可以用来拟合曲线。
使用线性模型拟合曲线
首先,让我们回顾一下如何使用 Incanter 的linear-model
函数拟合直线。我们要从数据集中提取x5
和x6
列,并将它们(按顺序:x6
,年份,是我们的预测变量)应用于incanter.stats/linear-model
函数。
(defn ex-9-3 []
(let [data (d/get-dataset :longley)
model (s/linear-model (i/$ :x5 data)
(i/$ :x6 data))]
(println "R-square" (:r-square model))
(-> (c/scatter-plot (i/$ :x6 data)
(i/$ :x5 data)
:x-label "Year"
:y-label "Population")
(c/add-lines (i/$ :x6 data)
(:fitted model))
(i/view))))
;; R-square 0.9879
前面的代码生成了以下图表:
尽管直线拟合数据的效果相当好,R²值超过 0.98,但它并未捕捉到线的曲线。特别是,我们可以看到图表两端以及中间的点偏离了直线。我们简单的模型有很高的偏差,且根据年份的不同,系统性地低估和高估了人口。残差的图表清楚地显示,误差并不是均匀分布的。
linear-model
函数之所以被称为线性模型,是因为它生成的模型与其参数之间存在线性关系。然而,或许出乎意料的是,只要我们提供非线性特征,它也能够生成非线性预测。例如,我们可以在年份之外,添加年份的*方作为一个参数。在以下代码中,我们使用 Incanter 的bind-columns
函数将这两个特征结合起来,形成一个矩阵:
(defn ex-9-4 []
(let [data (d/get-dataset :longley)
x (i/$ :x6 data)
xs (i/bind-columns x (i/sq x))
model (s/linear-model (i/$ :x5 data) xs)]
(println "R-square" (:r-square model))
(-> (c/scatter-plot (i/$ :x6 data)
(i/$ :x5 data)
:x-label "Year"
:y-label "Population")
(c/add-lines (i/$ :x6 data)
(:fitted model))
(i/view))))
;; 0.9983
我们的R²值有所提高,得到以下图表:
这显然更适合数据。我们可以通过创建一个 forecast
函数来使用我们的模型进行预测,该函数接受模型的系数并返回一个关于 x
(年份)的函数,将系数乘以我们定义的特征:
(defn forecast [coefs]
(fn [x]
(first
(i/mmult (i/trans coefs)
(i/matrix [1.0 x (i/sq x)])))))
系数包括了一个偏置项参数,因此我们将系数乘以 1.0、x 和 x²。
(defn ex-9-5 []
(let [data (d/get-dataset :longley)
x (i/$ :x6 data)
xs (i/bind-columns x (i/sq x))
model (s/linear-model (i/$ :x5 data) xs)]
(-> (c/scatter-plot (i/$ :x6 data)
(i/$ :x5 data)
:x-label "Year"
:y-label "Population")
(c/add-function (forecast (:coefs model))
1947 1970)
(i/view))))
接下来,我们将函数图扩展到 1970 年,以更清晰地看到拟合模型的曲线,如下所示:
当然,我们正在进行超出数据范围的外推。如同在第三章《相关性》中讨论的那样,外推太远通常是不明智的。为了更清楚地说明这一点,让我们关注一下 Longley 数据集中的另一个列,即武装力量的规模:x6
。
我们可以像之前一样绘制它:
(defn ex-9-6 []
(let [data (d/get-dataset :longley)]
(-> (c/scatter-plot (i/$ :x6 data)
(i/$ :x4 data)
:x-label "Year"
:y-label "Size of Armed Forces")
(i/view))))
这生成了以下图表:
这显然是一个更复杂的序列。我们可以看到,在 1950 年到 1952 年间,武装力量的规模急剧增加,随后出现了缓慢下降。在 1950 年 6 月 27 日,杜鲁门总统命令空军和海军支援韩国,这成为了后来所称的朝鲜战争。
要拟合这些数据的曲线,我们需要生成高阶多项式。首先,让我们构建一个 polynomial-forecast
函数,它将基于单一的 x
和要创建的最高次多项式自动生成更高阶的特征:
(defn polynomial-forecast [coefs degree]
(fn [x]
(first
(i/mmult (i/trans coefs)
(for [i (range (inc degree))]
(i/pow x i))))))
例如,我们可以使用以下代码训练一个模型,直到 x11:
(defn ex-9-7 []
(let [data (d/get-dataset :longley)
degree 11
x (s/sweep (i/$ :x6 data))
xs (reduce i/bind-columns
(for [i (range (inc degree))]
(i/pow x i)))
model (s/linear-model (i/$ :x4 data) xs
:intercept false)]
(println "R-square" (:r-square model))
(-> (c/scatter-plot (i/$ 1 xs) (i/$ :x4 data)
:x-label "Distance from Mean (Years)"
:y-label "Size of Armed Forces")
(c/add-function (polynomial-forecast (:coefs model)
degree)
-7.5 7.5)
(i/view))))
;; R-square 0.9755
上述代码生成了以下图表:
曲线拟合数据相当好,R²超过 0.97。然而,现在你应该不感到惊讶,我们实际上是在过拟合数据。我们构建的模型可能没有太多的预测能力。事实上,如果我们像在 ex-9-8
中那样将图表的范围向右延伸,展示未来的预测,我们将得到以下结果:
在最后一次测量数据点后的两年半,我们的模型预测军事人数将增长超过 500%,达到超过 175,000 人。
时间序列分解
我们在建模军事时间序列时面临的一个问题是,数据量不足以生成一个能够代表产生该系列过程的通用模型。建模时间序列的常见方法是将序列分解为若干个独立的组件:
-
趋势:序列是否随时间总体上升或下降?这种趋势是否像我们在观察人口时看到的那样是指数曲线?
-
季节性:序列是否表现出周期性的涨跌,且周期数目固定?对于月度数据,通常可以观察到 12 个月的周期性循环。
-
周期:数据集中是否存在跨越多个季节的长期周期?例如,在金融数据中,我们可能会观察到与扩张和衰退周期相对应的多年度周期。
另一个指定军事数据问题的方式是,由于没有足够的信息,我们无法确定是否存在趋势,或者观察到的峰值是否属于季节性或周期性模式的一部分。尽管数据似乎有上升的趋势,但也有可能我们正在密切观察一个最终会回落的周期。
本章我们将研究的一个数据集是经典的时间序列数据,观察的是 1949 到 1960 年间的月度航空公司乘客人数。该数据集较大,并且明显展示了趋势和季节性成分。
检查航空公司数据
与 Longley 数据集类似,航空公司数据集也是 Incanter 数据集库的一部分。我们加载incanter.datasets
库为d
,并将incanter.code
库加载为i
。
(defn ex-9-9 []
(-> (d/get-dataset :airline-passengers)
(i/view)))
前几行数据应如下所示:
在分析时间序列时,确保数据按时间顺序排列是非常重要的。该数据按年份和月份排序。所有一月的数据都排在所有二月数据之前,以此类推。为了进一步处理,我们需要将年份和月份列转换为一个可以排序的单一列。为此,我们将再次使用clj-time
库(github.com/clj-time/clj-time
)。
可视化航空公司数据
在第三章相关性中解析时间时,我们能够利用时间的字符串表示是 clj-time 默认理解的格式。自然,clj-time 并不能自动推断所有时间表示的格式。特别是,美国格式的mm/dd/yyyy与世界大多数地方偏好的dd/mm/yyyy格式之间的差异尤为问题。在clj-time.format
命名空间中提供了一个parse
函数,允许我们传递一个格式字符串,指示库如何解释该字符串。在以下代码中,我们将format
命名空间作为tf
引入,并指定我们的时间将采用"MMM YYYY"
格式。
注意
一个由 clj-time 使用的格式化字符串列表可以在www.joda.org/joda-time/key_format.html
找到。
换句话说,是三个字符表示“月”,后面跟着四个字符表示“年”。
(def time-format
(tf/formatter "MMM YYYY"))
(defn to-time [month year]
(tf/parse time-format (str month " " year)))
有了之前的函数,我们可以将年份和月份列解析成一个单一的时间,按顺序排列它们,并提取乘客人数:
(defn airline-passengers []
(->> (d/get-dataset :airline-passengers)
(i/add-derived-column :time [:month :year] to-time)
(i/$order :time :asc)
(i/$ :passengers)))
结果是一组按时间顺序排列的乘客人数数据。现在让我们将其作为折线图来可视化:
(defn timeseries-plot [series]
(-> (c/xy-plot (range (count series)) series
:x-label "Time"
:y-label "Value")
(i/view)))
(defn ex-9-10 []
(-> (airline-passengers)
(timeseries-plot)))
上述代码生成了以下图表:
你可以看到数据呈现出明显的季节性模式(每 12 个月重复一次)、上升趋势和缓慢增长曲线。
图表右侧的方差大于左侧的方差,因此我们说数据表现出一定的异方差性。我们需要去除数据集中的方差增加和上升趋势。这样会得到一个*稳的时间序列。
*稳性
一个*稳的时间序列是指其统计特性随时间保持不变的序列。大多数统计预测方法假设序列已经被转换为*稳序列。对于*稳的时间序列,预测变得更加容易:我们假设该序列的统计特性在未来与过去相同。为了去除航空公司数据中的方差增长和增长曲线,我们可以简单地对乘客数量取对数:
(defn ex-9-11 []
(-> (airline-passengers)
(i/log)
(timeseries-plot)))
这生成了以下图表:
取对数的效果是双重的。首先,初始图表中显现的异方差性被去除了。其次,指数增长曲线被转化为一条直线。
这使得数据变得更容易处理,但我们仍然在序列中看到趋势,也称为漂移。为了获得真正*稳的时间序列,我们还需要使均值稳定。实现这一点有几种方法。
去趋势和差分
第一种方法是去趋势处理序列。在取对数后,航空公司数据集包含了一个非常强的线性趋势。我们可以为这些数据拟合一个线性模型,然后绘制残差图:
(defn ex-9-12 []
(let [data (i/log (airline-passengers))
xs (range (count data))
model (s/linear-model data xs)]
(-> (c/xy-plot xs (:residuals model)
:x-label "Time"
:y-label "Residual")
(i/view))))
这生成了以下图表:
残差图显示了一个均值比原始序列更稳定的序列,并且上升趋势已被完全去除。然而,不幸的是,残差似乎并未围绕新均值呈现正态分布。特别是图表中间似乎出现了一个“驼峰”。这表明我们的线性模型在航空公司数据上表现不佳。我们可以像本章开始时那样拟合一条曲线,但让我们改为查看另一种使时间序列*稳的方法。
第二种方法是差分。如果我们从时间序列中的每个点中减去其直接前一个点的值,我们将得到一个新的时间序列(少一个数据点),其中只包含相邻点之间的差异。
(defn difference [series]
(map - (drop 1 series) series))
(defn ex-9-13 []
(-> (airline-passengers)
(i/log)
(difference)
(timeseries-plot)))
我们可以在以下图表中看到这一效果:
注意,上升趋势已经被替换为围绕常数均值波动的序列。均值略高于零,这对应于差异更可能为正,并导致我们观察到的上升趋势。
这两种技术的目标都是使序列的均值保持恒定。在某些情况下,可能需要对序列进行多次差分,或者在去趋势之后应用差分,以获得一个真正均值稳定的序列。例如,在去趋势之后,序列中仍然可以看到一些漂移,因此在本章的其余部分,我们将使用差分后的数据。
在继续讨论如何为预测建模这样的时间序列之前,让我们绕个弯,思考一下什么是时间序列,以及我们如何将时间序列建模为一个递归过程。
离散时间模型
离散时间模型,如我们迄今为止所看的那些,将时间划分为定期的时间片。为了使我们能够预测未来时间片的值,我们假设它们依赖于过去的时间片。
注意
时间序列也可以根据频率而不是时间进行分析。我们在本章中不讨论频域分析,但书籍的维基页面wiki.clojuredatascience.com
包含了更多资源的链接。
在以下内容中,令y[t]表示时间t时刻观察值的值。最简单的时间序列是每个时间片的值与前一个时间片的值相同。此类序列的预测器为:
这就是说,在时间t + 1的预测值给定时间t时等于时间t的观察值。请注意,这个定义是递归的:时间t的值依赖于时间t - 1的值。时间t - 1的值依赖于时间t - 2的值,依此类推。
我们可以将这个“常数”时间序列建模为 Clojure 中的惰性序列,其中序列中的每个值都是常数值:
(defn constant-series [y]
(cons y (lazy-seq (constant-series y))))
(defn ex-9-14 []
(take 5 (constant-series 42)))
;; (42 42 42 42 42)
注意constant-series
的定义中包含了对其自身的引用。这是一个递归函数定义,它创建了一个无限的惰性序列,我们可以从中获取值。
下一时刻,即时间t + 1,实际观察到的值为y[t+1]。如果此值与我们的预测值不同,则我们可以将这个差异计算为预测的误差:
通过结合前两个方程,我们得到了时间序列的随机模型。
换句话说,当前时间片的值等于前一个时间片的值加上一些误差。
随机漫步
最简单的随机过程之一是随机漫步。让我们将constant-series
扩展为一个random-walk
过程。我们希望我们的误差服从均值为零且方差恒定的正态分布。我们将通过调用 Incanter 的stats/sample-normal
函数来模拟随机噪声。
(defn random-walk [y]
(let [e (s/sample-normal 1)
y (+ y e)]
(cons y (lazy-seq (random-walk y)))))
(defn ex-9-15 []
(->> (random-walk 0)
(take 50)
(timeseries-plot)))
当然,您会得到不同的结果,但它应该类似于以下图表:
随机游走模型在金融学和计量经济学中非常常见。
注意
随机游走这一术语最早由卡尔·皮尔逊在 1905 年提出。许多过程——从波动的股价到分子在气体中运动时所描绘的路径——都可以被建模为简单的随机游走。1973 年,普林斯顿经济学家伯顿·戈登·马基尔在他的著作《华尔街随机漫步》中提出,股价也遵循随机游走的规律。
随机游走并非完全无法预测。虽然每个点与下一个点之间的差异由随机过程决定,但该差异的方差是恒定的。这意味着我们可以估计每一步的置信区间。然而,由于均值为零,我们无法有任何信心地判断差异相对于当前值是正的还是负的。
自回归模型
我们在本章中已经看到如何使用线性模型基于预测变量的线性组合做出预测。在自回归模型中,我们通过使用该变量过去的值的线性组合来预测感兴趣的变量。
自回归模型将预测变量与其自身回归。为了实际观察这一过程,让我们看一下以下代码:
(defn ar [ys coefs sigma]
(let [e (s/sample-normal 1 :sd sigma)
y (apply + e (map * ys coefs))]
(cons y (lazy-seq
(ar (cons y ys) coefs sigma)))))
这与我们在几页前遇到的随机游走递归定义有很多相似之处。然而,这次我们通过将之前的ys
与coefs
相乘,生成每一个新的y
。
我们可以通过调用我们新的ar
函数,传入之前的ys
和自回归模型的系数,来生成一个自回归序列:
(defn ex-9-16 []
(->> (ar [1] [2] 0)
(take 10)
(timeseries-plot)))
这将生成以下图表:
通过取初始值 1.0 和系数 2.0,且无噪声,我们正在创建一个指数增长曲线。序列中的每一个时间步都是二的幂。
自回归序列被称为自相关的。换句话说,每个点与其前面的点是线性相关的。在之前的例子中,这只是前一个值的两倍。系数的数量被称为自相关模型的阶数,通常用字母p表示。因此,前面的例子是一个p=1的自回归过程,或者是AR(1)过程。
通过增加p,可以生成更复杂的自回归序列。
(defn ex-9-17 []
(let [init (s/sample-normal 5)]
(->> (ar init [0 0 0 0 1] 0)
(take 30)
(timeseries-plot))))
例如,前面的代码生成了一个 5 阶自回归时间序列,或AR(5)序列。效果在序列中表现为一个周期为 5 个点的规律性循环。
我们可以将自回归模型与一些噪声结合起来,引入我们之前看到的随机游走组件。让我们将 sigma 增加到 0.2:
(defn ex-9-18 []
(let [init (s/sample-normal 5)]
(->> (ar init [0 0 0 0 1] 0.2)
(take 30)
(timeseries-plot))))
这将生成以下图表:
请注意,每五个点的典型“季节性”周期是如何被保留下来的,但也与一些噪声元素结合在一起。虽然这是模拟数据,但这个简单的模型已经开始接*在时间序列分析中经常出现的系列类型。
一阶滞后 AR 模型的一般方程如下:
其中c是某个常数,ε[t]是误差,y[t-1]是序列在上一个时间步的值,φ[1]是由希腊字母phi表示的系数。更一般地,自回归模型在p个滞后下的方程为:
由于我们的序列是*稳的,因此我们在代码中省略了常数项c。
在 AR 模型中确定自相关
就像线性回归可以建立多个自变量之间的(线性)相关性一样,自回归可以建立一个变量与自身在不同时间点之间的相关性。
就像在线性回归中,我们试图建立预测变量与响应变量之间的相关性一样,在时间序列分析中,我们试图建立时间序列与自身在一定数量滞后下的自相关。知道存在自相关的滞后数量可以让我们计算出自回归模型的阶数。
由此可得,我们想要研究序列在不同滞后下的自相关。例如,零滞后意味着我们将每个点与其本身进行比较(自相关为 1.0)。滞后 1 意味着我们将每个点与直接前面的点进行比较。自相关函数(ACF)是一个数据集与自身在给定滞后下的线性依赖关系。因此,ACF 由滞后k参数化。
Incanter 包含一个auto-correlation
函数,可以返回给定序列和滞后的自相关。然而,我们定义了自己的autocorrelation
函数,它将返回一系列滞后的autocorrelation
:
(defn autocorrelation* [series variance n k]
(let [lag-product (->> (drop k series)
(map * series)
(i/sum))]
(cons (/ lag-product variance n)
(lazy-seq
(autocorrelation* series variance n (inc k))))))
(defn autocorrelation [series]
(autocorrelation* (s/sweep series)
(s/variance series)
(dec (count series)) 0))
在计算自相关之前,我们使用incanter.stats
的sweep
函数来从序列中去除均值。这意味着我们可以简单地将序列中的值与滞后* k *的值相乘,以确定它们是否有一起变化的趋势。如果有,它们的乘积将是正的;如果没有,它们的乘积将是负的。
该函数返回一个无限懒加载的自相关值序列,对应于滞后0...k的自相关。我们来定义一个函数,将这些值绘制成条形图。与timeseries-plot
一样,该函数将接受一个有序的值序列:
(defn bar-plot [ys]
(let [xs (range (count ys))]
(-> (c/bar-chart xs ys
:x-label "Lag"
:y-label "Value")
(i/view))))
(defn ex-9-19 []
(let [init (s/sample-normal 5)
coefs [0 0 0 0 1]]
(->> (ar init coefs 0.2)
(take 100)
(autocorrelation)
(take 15)
(bar-plot))))
这生成了以下图表:
每隔 5 个滞后的峰值与我们的AR(5)系列生成器一致。随着噪声对信号的干扰,峰值逐渐减弱,测得的自相关也随之下降。
移动*均模型
AR 模型的一个假设是噪声是随机的,且均值和方差恒定。我们的递归 AR 函数从正态分布中抽取值来生成满足这些假设的噪声。因此,在 AR 过程中,噪声项是相互不相关的。
然而,在某些过程中,噪声项本身并不是不相关的。以一个示例来说明,假设有一个时间序列记录了每天的烧烤销售数量。我们可能会观察到每隔 7 天就有一个峰值,代表着顾客在周末更倾向于购买烧烤。偶尔,我们可能会观察到几周的销售总量下降,另外几周销售则会相应上升。我们可能推测这是由天气造成的,差的销售额对应于寒冷或雨天的时期,而好的销售额则对应于有利天气的时期。无论是什么原因,这种现象都会以均值明显变化的形式出现在我们的数据中。展现此行为的系列被称为移动*均(MA)模型。
一阶移动*均模型,记作MA(1),为:
其中,μ 是系列的均值,ε[t] 是噪声值,θ[1] 是模型的参数。更一般地,对于q项,MA 模型可表示为:
因此,MA 模型本质上是当前系列值与当前及前一时刻(未观测到的)白噪声误差项或随机冲击的线性回归。假设每个点的误差项是相互独立的,并且来自同一(通常为正态)分布,具有零均值和恒定方差。
在 MA 模型中,我们假设噪声值本身是自相关的。我们可以这样建模:
(defn ma [es coefs sigma]
(let [e (s/sample-normal 1 :sd sigma)
y (apply + e (map * es coefs))]
(cons y (lazy-seq
(ma (cons e es) coefs sigma)))))
这里,es
是前一时刻的误差,coefs
是 MA 模型的参数,sigma
是误差的标准差。
注意该函数与之前定义的ar
函数的不同。我们不再保留前一系列的ys,而是保留前一系列的es。接下来,让我们看看 MA 模型生成的系列:
(defn ex-9-20 []
(let [init (s/sample-normal 5)
coefs [0 0 0 0 1]]
(->> (ma init coefs 0.5)
(take 100)
(timeseries-plot))))
这会生成类似于以下的图形:
你可以看到,MA 模型的图表没有 AR 模型那样明显的重复。如果从更长的时间序列来看,你可以看到它如何重新引入漂移,因为一个随机冲击的反响在新的临时均值中得以延续。
确定 MA 模型中的自相关
你可能会想,是否可以通过自相关图来帮助识别 MA 过程。我们现在就来绘制一下。MA 模型可能更难识别,因此我们将在绘制自相关之前生成更多数据点。
(defn ex-9-21 []
(let [init (s/sample-normal 5)
coefs [0 0 0 0 1]]
(->> (ma init coefs 0.2)
(take 5000)
(autocorrelation)
(take 15)
(bar-plot))))
这将生成以下图表:
你可以在前面的图表中看到,MA 过程的阶数被清晰地显示出来,在滞后 5 的位置有一个明显的峰值。请注意,然而,与自回归过程不同,这里没有每 5 个滞后就出现一个周期性的峰值。这是 MA 过程的一个特征,因为该过程引入了漂移至均值,自相关在其他滞后下大幅减弱。
结合 AR 和 MA 模型
到目前为止,我们所考虑的 AR 和 MA 模型是生成自相关时间序列的两种不同但紧密相关的方法。它们并不是互相排斥的,实际上在建模实际时间序列时,你经常会遇到序列看起来是两者的混合情况。
我们可以将 AR 和 MA 过程结合成一个单一的 ARMA 模型,包含两组系数:自回归模型的系数和移动*均模型的系数。每个模型的系数数量不必相同,按照惯例,AR 模型的阶数用p表示,而 MA 模型的阶数用q表示。
(defn arma [ys es ps qs sigma]
(let [e (s/sample-normal 1 :sd sigma)
ar (apply + (map * ys ps))
ma (apply + (map * es qs))
y (+ ar ma e)]
(cons y (lazy-seq
(arma (cons y ys)
(cons e es)
ps qs sigma)))))
让我们绘制一个更长的数据点序列,看看会出现什么样的结构:
(defn ex-9-22 []
(let [ys (s/sample-normal 10 :sd 1.0)
es (s/sample-normal 10 :sd 0.2)
ps [0 0 0 0.3 0.5]
qs [0.2 0.8]]
(->> (arma ys es ps qs 0.2)
(take 500)
(timeseries-plot))))
注意我们如何为模型的 AR 部分和 MA 部分指定不同数量的参数:AR 部分为 5 个参数,MA 模型为 2 个参数。这被称为ARMA(5,2)模型。
之前的 ARMA 模型在更长的点序列上绘制,可以让 MA 项的效果变得明显。在这个尺度下,我们看不到 AR 成分的效果,因此让我们像之前一样通过自相关图来运行这个序列:
(defn ex-9-23 []
(let [ys (s/sample-normal 10 :sd 1.0)
es (s/sample-normal 10 :sd 0.2)
ps [0 0 0 0.3 0.5]
qs [0.2 0.8]]
(->> (arma ys es ps qs 0.2)
(take 500)
(autocorrelation)
(take 15)
(bar-plot))))
你应该看到一个类似以下的图表:
事实上,随着更多数据和 AR 与 MA 成分的加入,ACF 图并没有让序列的阶数变得更加清晰,相反,它不像我们之前看到的那些自相关图那么明确和清晰。自相关缓慢衰减至零,这使得我们即使提供了大量数据,也无法确定 AR 和 MA 过程的阶数。
这样做的原因是 MA 部分的影响压倒了 AR 部分。我们无法在数据中识别出周期性模式,因为它被一个移动*均所掩盖,这使得所有相邻的点看起来都存在相关性。解决这个问题的最佳方法是绘制偏自相关。
计算偏自相关
部分自相关函数(PACF)旨在解决在混合 ARMA 模型中识别周期性成分的问题。它被定义为给定所有中间观测值时,y[t] 和 y[t+k] 之间的相关系数。换句话说,它是在滞后 k 下的自相关,且该自相关并未被滞后 1 到滞后 k-1 的自相关所涵盖。
一阶滞后 1 的部分自相关被定义为等于一阶自相关。二阶滞后 2 的部分自相关等于:
这是两个时间周期间隔的值之间的相关性,y[t] 和 y[t-2],条件是已知 y[t-1]。在*稳时间序列中,分母中的两个方差将是相等的。
三阶滞后 3 的部分自相关等于:
以此类推,适用于任何滞后。
自协方差
部分自相关的公式要求我们计算数据在某个滞后下的自协方差。这称为自协方差。我们在前几章已经学习了如何测量两组数据之间的协方差,也就是两项或多项属性一起变化的趋势。这个函数与我们之前在本章定义的自相关函数非常相似,计算从零开始的一系列滞后的自协方差:
(defn autocovariance* [series n k]
(let [lag-product (->> (drop k series)
(map * series)
(i/sum))]
(cons (/ lag-product n)
(lazy-seq
(autocovariance* series n (inc k))))))
(defn autocovariance [series]
(autocovariance* (s/sweep series) (count series) 0))
如前所述,返回值将是一个懒序列(lazy sequence)滞后值,所以我们只需要取我们所需的值。
使用 Durbin-Levinson 递推法的 PACF
由于需要考虑先前已解释的变化,计算部分自相关比计算自相关要复杂得多。Durbin-Levinson 算法提供了一种递归计算的方法。
注意
Durbin-Levinson 递推法,简称 Levinson 递推法,是一种计算涉及对角线元素为常数的矩阵(称为托普利茨矩阵)方程解的方法。更多信息请参考 en.wikipedia.org/wiki/Levinson_recursion
。
Levinson 递推法的实现如下所示。数学推导超出了本书的范围,但递推函数的一般形状你现在应该已经熟悉了。在每次迭代中,我们通过上一轮部分自相关和自协方差的函数来计算部分自相关。
(defn pac* [pacs sigma prev next]
(let [acv (first next)
sum (i/sum (i/mult pacs (reverse prev)))
pac (/ (- acv sum) sigma)]
(cons pac
(lazy-seq
(pac* (->> (i/mult pacs pac)
(reverse)
(i/minus pacs)
(cons pac))
(* (- 1 (i/pow pac 2)) sigma)
(cons acv prev)
(rest next))))))
(defn partial-autocorrelation [series]
(let [acvs (autocovariance series)
acv1 (first acvs)
acv2 (second acvs)
pac (/ acv2 acv1)]
(concat [1.0 pac]
(pac* (vector pac)
(- acv1 (* pac acv2))
(vector acv2)
(drop 2 acvs)))))
如前所述,这个函数将创建一个无限的懒序列部分自相关,所以我们只能从中取出我们实际需要的数字。
绘制部分自相关
现在我们已经实现了一个计算时间序列部分自相关的函数,接下来就来绘制它们。我们将使用与之前相同的 ARMA 系数,以便进行差异比较。
(defn ex-9-24 []
(let [ys (s/sample-normal 10 :sd 1.0)
es (s/sample-normal 10 :sd 0.2)
ps [0 0 0 0.3 0.5]
qs [0.2 0.8]]
(->> (arma ys es ps qs 0.2)
(take 500)
(partial-autocorrelation)
(take 15)
(bar-plot))))
这应该生成一个类似于以下的条形图:
幸运的是,这与我们之前绘制的 ACF 图有所不同。在滞后 1 和滞后 2 处有很高的部分自相关。这表明正在进行一个MA(2)过程。然后,滞后 5 之前部分自相关较低,这表明也存在一个AR(5)模型。
使用 ACF 和 PACF 确定 ARMA 模型阶数
ACF 和 PACF 图之间的差异有助于选择最合适的时间序列模型。下表描述了理想化 AR 和 MA 序列的 ACF 和 PACF 图的特征。
模型 | ACF | PACF |
---|---|---|
AR(p) | 渐渐衰减 | 在p滞后之后截断 |
MA(q) | 在* q *滞后之后截断 | 渐渐衰减 |
ARMA(p,q) | 渐渐衰减 | 渐渐衰减 |
然而,我们常常面对的数据并不符合这些理想情况。给定一个实际的时间序列,尤其是没有足够多数据点的序列,通常不容易明确哪个模型最为合适。最好的做法通常是选择最简单的模型(即最低阶的模型),它能有效描述你的数据。
上面的插图展示了理想化AR(1)序列的 ACF 和 PACF 示例图。接下来是理想化MA(1)序列的 ACF 和 PACF 示例图。
图中的虚线表示显著性阈值。一般来说,我们无法创建一个完美捕捉时间序列中所有自相关的模型,显著性阈值帮助我们优先考虑最重要的部分。使用 5%显著性水*的显著性阈值的简单公式是:
在这里,n是时间序列中的数据点数量。如果 ACF 和 PACF 中的所有点都接*零,则数据基本上是随机的。
航空数据的 ACF 和 PACF
让我们回到之前开始考虑的航空数据,并绘制数据的前 25 个滞后的 ACF。
(defn ex-9-25 []
(->> (airline-passengers)
(difference)
(autocorrelation)
(take 25)
(bar-plot)))
这段代码生成了以下图表:
可以看到数据中有规律的峰值和谷值。第一个峰值出现在滞后 12;第二个出现在滞后 24。由于数据是按月记录的,这些峰值对应着年度季节性循环。因为我们的时间序列有 144 个数据点,显著性阈值大约为 或 0.17。
接下来,让我们看一下航空数据的部分自相关图:
(defn ex-9-26 []
(->> (airline-passengers)
(difference)
(partial-autocorrelation)
(take 25)
(bar-plot)))
这段代码生成了以下图表:
部分自相关图在滞后 12 处也有一个峰值。与自相关图不同,它在滞后 24 处没有峰值,因为周期性自相关已在滞后 12 时被考虑在内。
尽管这看起来表明 AR(12)模型会合适,但这会创建大量的系数需要学习,特别是在数据量相对较少的情况下。由于周期性循环是季节性的,我们应该通过第二次差分来去除它。
通过差分去除季节性
我们已经对数据进行了第一次差分,这意味着我们的模型被称为自回归积分滑动*均(ARIMA)模型。差分的级别由参数d给出,因此完整的模型阶数可以指定为ARIMA(p,d,q)。
我们可以对数据进行第二次差分,以去除数据中的强季节性。接下来我们来做这个:
(defn ex-9-27 []
(->> (airline-passengers)
(difference)
(difference 12)
(autocorrelation)
(take 15)
(bar-plot)))
首先,我们绘制自相关图:
接下来,部分自相关:
(defn ex-9-28 []
(->> (airline-passengers)
(difference)
(difference 12)
(partial-autocorrelation)
(take 15)
(bar-plot)))
这会生成以下图表:
强季节性周期解释了图表中大部分的显著性。在两个图表中,我们剩下了滞后 1 的负自相关,以及在 ACF 中滞后 9 的微弱显著自相关。一个一般的经验法则是,正自相关最好通过在模型中添加AR项来处理,而负自相关通常最好通过添加MA项来处理。
从之前的图表来看,似乎一个合理的模型是MA(1)模型。这可能是一个足够好的模型,但我们可以借此机会演示如何通过尝试捕捉AR(9)自相关来拟合大量的参数。
我们将考虑成本函数的替代方案——似然度,它衡量给定模型与数据的拟合程度。模型拟合得越好,似然度越大。因此,我们希望最大化似然度,这个目标也称为最大似然估计。
最大似然估计
在本书的多次场合,我们已经将优化问题表示为需要最小化的成本函数。例如,在第四章,分类中,我们使用 Incanter 最小化逻辑回归成本函数,同时建立一个逻辑回归分类器;在第五章,大数据中,我们在执行批量和随机梯度下降时使用梯度下降法最小化最小二乘成本函数。
优化也可以表示为要最大化的效益,有时用这种方式思考会更自然。最大似然估计的目标是通过最大化似然函数来找到模型的最佳参数。
假设给定模型参数β时,观测值x的概率表示为:
然后,似然度可以表示为:
似然是给定数据下参数的概率的度量。最大似然估计的目标是找到使观察到的数据最有可能的参数值。
计算似然
在计算时间序列的似然之前,我们将通过一个简单的例子来说明这一过程。假设我们投掷硬币 100 次,观察到 56 次正面,h,44 次反面,t。我们不假设硬币是公*的,P(h)=0.5(因此,稍微不*等的总数是由于偶然变动的结果),而是可以问观察到的值是否与 0.5 有显著差异。我们可以通过询问什么值的P(h)使得观察到的数据最有可能来做到这一点。
(defn ex-9-29 []
(let [data 56
f (fn [p]
(s/pdf-binomial data :size 100 :prob p))]
(-> (c/function-plot f 0.3 0.8
:x-label "P"
:y-label "Likelihood")
(i/view))))
在上面的代码中,我们使用二项分布来模拟投掷硬币的序列(回想一下在第四章中提到的分类,二项分布用于模拟二元结果发生的次数)。关键点是数据是固定的,我们在绘制给定不同参数下,观察到该数据的变化概率。以下图表展示了似然面:
正如我们可能预期的,二项分布中最可能的参数是p=0.56。这个人工设定的例子本可以通过手工计算更简单地得出,但最大似然估计的原理能够应对更为复杂的模型。
事实上,我们的 ARMA 模型就是一个复杂的模型。计算时间序列参数似然的数学超出了本书的范围。我们将使用 Clojure 库 Succession(github.com/henrygarner/succession
)来计算时间序列的似然。
我们通常使用对数似然而不是似然。这仅仅是出于数学上的方便,因为对数似然:
可以重写为:
在这里,k是模型的参数个数。求多个参数的和比求其积更方便进行计算,因此第二个公式通常更受青睐。让我们通过绘制不同参数下的对数似然与简单的AR(2)时间序列的关系,来感受一下似然函数在一些测试数据上的表现。
(defn ex-9-30 []
(let [init (s/sample-normal 2)
coefs [0 0.5]
data (take 100 (ar init coefs 0.2))
f (fn [coef]
(log-likelihood [0 coef] 2 0 data))]
(-> (c/function-plot f -1 1
:x-label "Coefficient"
:y-label "Log-Likelihood")
(i/view))))
上面的代码生成了以下图表:
曲线的峰值对应于根据数据得到的参数最佳估计值。注意,前面图表中的峰值略高于 0.5:我们为模型添加的噪声使得最佳估计并非恰好为 0.5\。
估计最大似然
我们的 ARMA 模型的参数数量很大,因此为了确定最大似然,我们将使用一种在高维空间中表现良好的优化方法。该方法叫做 Nelder-Mead 方法,或称为 simplex 方法。在 n 维空间中,简单形体是一个具有 n+1 个顶点的 多面体。
注意
多面体是一个具有*面边的几何对象,可以存在于任意维度中。二维多边形是 2-多面体,三维多面体是 3-多面体。
简单形体优化的优势在于,它不需要在每个点计算梯度来下降(或上升)到更优位置。Nelder-Mead 方法通过推测在每个测试点测量的目标函数行为来进行优化。最差的点将被一个通过剩余点的质心反射创建的新点所替代。如果新点比当前最优点更好,我们将沿此线指数地拉伸简单形体。如果新点和之前的点没有显著改善,我们可能是跨越了一个低谷,因此我们会将简单形体收缩向可能更优的点。
以下图示展示了简单形体(作为三角形表示)如何反射和收缩以找到最优参数的示例。
简单形体总是表示为一个形状,其顶点数量比维度数多一个。对于二维优化,如前面的图所示,简单形体表示为三角形。对于任意 n 维空间,简单形体将表示为一个 n+1 个顶点的多边形。
注意
简单形体方法也被称为 变形虫方法,因为它看起来像是在朝着更优位置爬行。
简单形体优化方法没有在 Incanter 中实现,但可以在 Apache Commons Math 库中使用(commons.apache.org/proper/commons-math/
)。要使用它,我们需要将我们的目标函数——对数似然——包装成库能够理解的表示方式。
使用 Apache Commons Math 进行 Nelder-Mead 优化
Apache Commons Math 是一个庞大且复杂的库。我们无法在这里覆盖太多细节。下一个示例仅用于展示如何将 Clojure 代码与该库提供的 Java 接口集成。
注意
有关 Apache Commons Math 库广泛优化功能的概述,可以参考 commons.apache.org/proper/commons-math/userguide/optimization.html
。
Apache Commons Math 库期望我们提供一个要优化的 ObjectiveFunction
。接下来,我们通过实现一个 MultivariateFunction
来创建它,因为我们的目标函数需要多个参数。我们的响应将是一个单一值:对数似然。
(defn objective-function [f]
(ObjectiveFunction. (reify MultivariateFunction
(value [_ v]
(f (vec v))))))
上述代码将返回一个ObjectiveFunction
表示的任意函数f
。MultivariateFunction
期望接收一个参数向量v
,我们将其直接传递给f
。
在此基础上,我们使用一些 Java 互操作性调用optimize
,在SimplexOptimizer
上使用一些合理的默认值。我们对参数的InitialGuess
只是一个零数组。NelderMeadSimplex
必须使用每个维度的默认步长进行初始化,该步长可以是任何非零值。我们选择每个参数的步长为 0.2。
(defn arma-model [p q ys]
(let [m (+ p q)
f (fn [params]
(sc/log-likelihood params p q ys))
optimal (.optimize (SimplexOptimizer. 1e-10 1e-10)
(into-array
OptimizationData
[(MaxEval. 100000)
(objective-function f)
GoalType/MAXIMIZE
(->> (repeat 0.0)
(take m)
(double-array)
(InitialGuess.))
(->> (repeat 0.1)
(take m)
(double-array)
(NelderMeadSimplex.))]))
point (-> optimal .getPoint vec)
value (-> optimal .getValue)]
{:ar (take p point)
:ma (drop p point)
:ll value}))
(defn ex-9-31 []
(->> (airline-passengers)
(i/log)
(difference)
(difference 12)
(arma-model 9 1)))
我们的模型是一个大模型,拥有许多参数,因此优化过程会花费一段时间才能收敛。如果你运行上面的示例,你最终会看到类似下面的返回参数:
;; {:ar (-0.23769808471685377 -0.012617164166298971 ...),
;; :ma (-0.14754455658280236),
;; :ll 232.97813750669314}
这些是我们模型的最大似然估计。此外,响应中还包含了使用最大似然参数的模型的对数似然值。
使用赤池信息量准则识别更好的模型
在评估多个模型时,可能会出现最佳模型是最大似然估计值最大的那个模型的情况。毕竟,最大似然估计表明该模型是生成观测数据的最佳候选者。然而,最大似然估计没有考虑模型的复杂性,通常来说,简单的模型更受青睐。回想一下章节开始时我们的高阶多项式模型,它虽然有很高的R²,但却没有提供任何预测能力。
赤池信息量准则(AIC)是一种用于比较模型的方法,它奖励拟合优度(通过似然函数评估),但同时也包括了与参数数量相关的惩罚项。这种惩罚项能有效避免过拟合,因为增加模型的参数数量几乎总是会提高拟合优度。
AIC 可以通过以下公式计算:
在这里,k是模型的参数数量,L是似然函数。我们可以在 Clojure 中通过以下方式使用参数计数p和q来计算 AIC。
(defn aic [coefs p q ys]
(- (* 2 (+ p q 1))
(* 2 (log-likelihood coefs p q ys))))
如果我们要生成多个模型并选择最佳的,我们会选择具有最低 AIC 的那个模型。
时间序列预测
在参数估计已被定义后,我们终于可以使用我们的模型进行预测了。实际上,我们已经编写了大部分需要的代码:我们有一个arma
函数,能够根据一些种子数据和模型参数p和q生成自回归滑动*均序列。种子数据将是我们从航空公司数据中测量到的y值,p和q的值是我们使用 Nelder-Mead 方法计算出来的参数。
让我们将这些数字代入我们的 ARMA 模型,并生成y的预测序列:
(defn ex-9-32 []
(let [data (i/log (airline-passengers))
diff-1 (difference 1 data)
diff-12 (difference 12 diff-1)
forecast (->> (arma (take 9 (reverse diff-12))
[]
(:ar params)
(:ma params) 0)
(take 100)
(undifference 12 diff-1)
(undifference 1 data))]
(->> (concat data forecast)
(i/exp)
(timeseries-plot))))
上述代码生成了以下图表:
到时间切片 144 为止的线条是原始序列。从此点之后的线条是我们的预测序列。预测看起来与我们预期的差不多:指数增长趋势持续下去,季节性波动的峰值和谷值模式也在继续。
实际上,预测几乎太规则了。与第 1 到 144 点的系列不同,我们的预测没有噪声。让我们加入一些噪声,使我们的预测更具现实性。为了确定噪声的合理性,我们可以查看过去预测中的误差。为了避免错误的积累,我们应该每次预测一个时间步长,并观察预测值与实际值之间的差异。
让我们用 0.02 的 sigma 值运行我们的 ARMA 函数:
(defn ex-9-33 []
(let [data (i/log (airline-passengers))
diff-1 (difference 1 data)
diff-12 (difference 12 diff-1)
forecast (->> (arma (take 9 (reverse diff-12))
[]
(:ar params)
(:ma params) 0.02)
(take 10)
(undifference 12 diff-1)
(undifference 1 data))]
(->> (concat data forecast)
(i/exp)
(timeseries-plot))))
上面的代码可能会生成如下图表:
现在我们可以感知到预测的波动性了。通过多次运行模拟,我们可以了解不同可能结果的多样性。如果我们能够确定预测的置信区间,包括噪声的上下期望值,这将非常有用。
蒙特卡洛模拟预测
尽管确实存在用于计算时间序列预期未来值的方法,并且可以得到置信区间,我们将在本节通过模拟来获得这些值。通过研究多个预测之间的变异性,我们可以为模型预测得出置信区间。
例如,如果我们运行大量的模拟,我们可以计算基于 95%时间内值范围的未来预测的 95%置信区间。这正是蒙特卡洛模拟的核心,它是一个常用于解决分析上难以处理问题的统计工具。
注意
蒙特卡洛方法是在曼哈顿计划期间开发并系统地使用的,这是美国在二战期间开发核武器的努力。约翰·冯·诺依曼和斯坦尼斯瓦夫·乌拉姆建议将其作为研究中子穿越辐射屏蔽的性质的一种手段,并以摩纳哥蒙特卡洛赌场命名该方法。
我们已经为时间序列预测的蒙特卡洛模拟奠定了所有基础。我们只需要多次运行模拟并收集结果。在以下代码中,我们运行 1,000 次模拟,并收集每个未来时间切片下所有预测的均值和标准差。通过创建两个新序列(一个上界,通过将标准差乘以 1.96 并加上标准差,另一个下界,通过将标准差乘以 1.96 并减去标准差),我们能够可视化系列未来值的 95%置信区间。
(defn ex-9-34 []
(let [data (difference (i/log (airline-passengers)))
init (take 12 (reverse data))
forecasts (for [n (range 1000)]
(take 20
(arma init [0.0028 0.0028]
(:ar params1)
(:ma params1)
0.0449)))
forecast-mean (map s/mean (i/trans forecasts))
forecast-sd (-> (map s/sd (i/trans forecasts))
(i/div 2)
(i/mult 1.96))
upper (->> (map + forecast-mean forecast-sd)
(concat data)
(undifference 0)
(i/exp))
lower (->> (map - forecast-mean forecast-sd)
(concat data)
(undifference 0)
(i/exp))
n (count upper)]
(-> (c/xy-plot (range n) upper
:x-label "Time"
:y-label "Value"
:series-label "Upper Bound"
:legend true)
(c/add-lines (range n) lower
:series-label "Lower Bound")
(i/view))))
这生成了以下图表:
上限和下限为我们对未来时间序列预测提供了置信区间。
总结
在本章中,我们考虑了分析离散时间序列的任务:在固定时间间隔内进行的顺序观察。我们看到,通过将序列分解为一组组件:趋势成分、季节性成分和周期性成分,可以使建模这种序列的挑战变得更加容易。
我们看到了 ARMA 模型如何将一个序列进一步分解为自回归(AR)和滑动*均(MA)组件,每个组件都以某种方式由序列的过去值决定。这种对序列的理解本质上是递归的,我们也看到了 Clojure 自然支持定义递归函数和懒序列的能力,如何有助于算法生成此类序列。通过将序列的每个值确定为前一个值的函数,我们实现了一个递归的 ARMA 生成器,它能够模拟一个已测量的序列并进行未来的预测。
我们还学习了期望最大化:一种将优化问题的解重新表述为根据数据生成最大可能性的解的方法。我们还看到了如何使用 Apache Commons Math 库,通过 Nelder-Mead 方法估计最大似然参数。最后,我们了解了如何通过将序列向前推进来进行预测,以及如何使用蒙特卡洛模拟来估计序列的未来误差。
在最后一章,我们将注意力从数据分析转向数据可视化。从某种程度上说,数据科学家的最重要挑战是沟通,我们将看到 Clojure 如何帮助我们以最有效的方式展示数据。
第十章. 可视化
“数字有一个重要的故事要告诉我们。它们依赖于你,给它们一个清晰且有说服力的声音。” | ||
---|---|---|
--Stephen Few |
本书中的每一章都以某种方式使用了可视化,主要使用 Incanter。Incanter 是一个有效的工具,可以在工作中制作各种各样的图表,这些图表通常是我们在试图理解数据集时首先会使用的。这个初步阶段通常被称为探索性数据分析,在这个阶段,我们感兴趣的是总结数据的统计信息,例如数值数据的分布、类别数据的计数,以及数据中属性之间的相关性。
在找到一种有意义的方式来解读数据后,我们通常会希望将其传达给他人。最重要的沟通工具之一就是可视化,我们可能需要向没有强大分析背景的人传达细微或复杂的概念。在本章中,我们将使用 Quil 库——这是为视觉艺术家开发的软件的延伸——来制作吸引人的图形,帮助使数据生动起来。可视化和沟通设计是广泛而丰富的领域,我们在这里无法详细覆盖。相反,本章将提供两个案例研究,展示如何将 Clojure 的数据抽象和 Quil 的绘图 API 结合起来,产生良好的效果。
本章的开始,我们将回到第一章,统计学中使用的数据。我们将通过演示如何从俄罗斯选举数据构建一个简单的二维直方图来介绍 Quil。在掌握了 Quil 绘图的基本知识后,我们将展示如何通过一些基本的绘图指令,结合起来,呈现美国财富分配的引人注目的图示。
下载代码和数据
在本章中,我们将回到本书第一章中使用的数据:2011 年俄罗斯选举的数据。在第一章,统计学中,我们使用了带透明度的散点图来可视化选民投票率与胜选者选票百分比之间的关系。在本章中,我们将编写代码,将数据渲染为二维直方图。
我们还将使用有关美国财富分配的数据。这个数据非常小,以至于我们不需要下载任何东西:我们将直接在源代码中输入这些数据。
注意
本章的源代码可以在github.com/clojuredatascience/ch10-visualization
找到。
本章的示例代码包含一个脚本,用于下载我们在第一章,统计学中使用的选举数据。一旦下载了源代码,你可以通过在项目根目录内运行以下命令来执行脚本:
script/download-data.sh
如果你之前下载了第一章的统计学数据,你可以将数据文件直接移动到本章的数据目录中(如果你愿意的话)。
探索性数据可视化
在任何数据科学项目的初期,通常会有一个迭代数据探索的阶段,你可以在这个阶段获得对数据的洞察。在本书中,Incanter 一直是我们的主要可视化工具。尽管它包含了大量的图表,但也会有一些场合,它不包含你想要表示的数据的理想图表。
注意
其他 Clojure 库正在提供探索性数据可视化功能。例如,查看clojurewerkz/envision github.com/clojurewerkz/envision
和 Karsten Schmidt 的thi-ng/geom,地址为github.com/thi-ng/geom/tree/master/geom-viz
。
例如,在第一章的统计学中,我们使用了带有透明度的散点图来可视化选民投票率与赢家得票比例之间的关系。这不是理想的图表,因为我们主要关注的是某个特定区域内点的密度。透明度有助于揭示数据的结构,但它不是一种明确的表示。一些点仍然太微弱,无法看清,或者数量太多,以至于它们看起来像一个点:
我们本可以通过二维直方图解决这些问题。这种类型的图表使用颜色来传达二维空间中高低密度的区域。图表被划分为一个网格,网格中的每个单元格表示两个维度的一个范围。点越多地落入网格的单元格中,该范围内的密度就越大。
表示二维直方图
直方图只是将连续分布表示为一系列箱子的方式。直方图在第一章的统计学中已经介绍,当时我们编写了一个分箱函数,将连续数据分为离散的箱子:
(defn bin [n-bins xs]
(let [min-x (apply min xs)
range-x (- (apply max xs) min-x)
max-bin (dec n-bins)
bin-fn (fn [x]
(-> (- x min-x)
(/ range-x)
(* n-bins)
(int)
(min max-bin)))]
(map bin-fn xs)))
这段代码将对连续的xs
范围进行分箱,并根据n-bins
参数将其分成不同的组。例如,将 0 到 19 的范围分为 5 个箱子,结果如下:
(defn ex-1-1 []
(bin 5 (range 20)))
;;(0 0 0 0 1 1 1 1 2 2 2 2 3 3 3 3 4 4 4 4)
bin
函数返回每个数据点的箱子索引,而不是计数,因此我们使用 Clojure 的frequencies
函数来确定落入该箱子的点的数量:
(defn ex-1-2 []
(frequencies (bin 5 (range 20))))
;;{0 4, 1 4, 2 4, 3 4, 4 4}
这是一个合理的一维直方图表示:它将计数的箱子映射出来。要表示二维直方图,我们只需要对xs和ys执行相同的计算。我们将向量函数映射到箱子的索引上,以便将每个点转换为[x-bin y-bin]
的表示:
(defn histogram-2d [xsys n-bins]
(-> (map vector
(bin n-bins xs)
(bin n-bins ys))
(frequencies)))
这个函数返回一个以两个值的向量为键的映射。frequencies
函数现在将计数所有在 x 和 y 轴上都共享同一个区间的点:
(defn ex-10-3 []
(histogram-2d (range 20)
(reverse (range 20)) 5))
;;{[0 4] 4, [1 3] 4, [2 2] 4, [3 1] 4, [4 0] 4}
我们希望在直方图中绘制实际数据,因此让我们从 第一章加载俄罗斯数据,统计学。如果你已经将数据下载到示例代码的 data
目录中,可以运行以下代码:
(defn ex-10-4 []
(let [data (load-data :ru-victors)]
(histogram-2d (i/$ :turnout data)
(i/$ :victors-share data) 5)))
;; {[4 3] 6782, [2 2] 14680, [0 0] 3, [1 0] 61, [2 3] 2593,
;; [3 3] 8171, [1 1] 2689, [3 4] 1188, [4 2] 3084, [3 0] 64,
;; [4 1] 1131, [1 4] 13, [1 3] 105, [0 3] 6, [2 4] 193, [0 2] 10,
;; [2 0] 496, [0 4] 1, [3 1] 3890, [2 1] 24302, [4 4] 10771,
;; [1 2] 1170, [3 2] 13384, [0 1] 4, [4 0] 264}
我们可以看到直方图区间中的值范围巨大:从区间 [0 4]
中的 1 到区间 [2 1]
中的 24,302。这些计数值将是我们在直方图上绘制的密度值。
使用 Quil 进行可视化
Quil (github.com/quil/quil
) 是一个 Clojure 库,提供了大量的灵活性来生成自定义的可视化效果。它封装了 Processing (processing.org/
),这是一个 Java 框架,已被视觉艺术家和设计师积极开发多年,旨在促进“视觉艺术中的软件素养和技术中的视觉素养”。
使用 Quil 进行的任何可视化都涉及创建一个 sketch。sketch 是处理程序的术语,指的是运行一个由绘图指令组成的程序。大多数 API 函数都可以从 quil.core
命名空间中调用。我们将其作为 q
包含在代码中。调用 q/sketch
并不传递任何参数时,将会弹出一个空窗口(尽管它可能会被其他窗口遮挡)。
绘制到 sketch 窗口
默认的窗口大小是 500px x 300px。我们希望我们的二维直方图是正方形的,因此将窗口的宽高都设置为 250px:
(q/sketch :size [250 250])
由于我们每个轴都有 5 个区间,因此每个区间将由一个 50px 宽和 50px 高的正方形表示。
Quil 提供了标准的二维形状原语用于绘制:点、线、弧、三角形、四边形、矩形和椭圆。要绘制一个矩形,我们调用 q/rect
函数,并指定 x 和 y 坐标,以及宽度和高度。
让我们在原点绘制一个宽度为 50px 的正方形。有几种方式可以向 Quil 提供绘图指令,但在本章中,我们将传递一个被称为 setup
的函数。这是一个没有参数的函数,我们将其传递给 sketch。我们的零参数函数仅仅是调用 rect
,并传入位置 [0, 0] 和宽高为 50:
(defn ex-10-5 []
(let [setup #(q/rect 0 0 50 50)]
(q/sketch :setup setup
:size [250 250])))
该代码生成了以下图像:
根据你对计算机图形学的熟悉程度,矩形的位置可能与预期不同。
注意
通过传递半径作为第五个参数,也可以绘制带有圆角的矩形。通过传递第五到第八个参数的值,可以为每个角使用不同的半径。
在继续之前,我们需要理解 Quil 的坐标系统。
Quil 的坐标系统
Quil 使用的坐标系统与 Processing 和大多数其他计算机图形程序相同。如果你不熟悉绘图,可能会觉得起点位于显示屏左上角是直觉上不对的。y 轴向下延伸,x 轴向右延伸。
很明显,这不是大多数图表中 y 轴的方向,这意味着在绘图时,y 坐标通常需要被翻转。
一种常见的方法是从草图底部测量的 y 值中减去所需的值,这样的转换使得 y 为零时对应草图的底部。较大的 y 值则对应草图中更高的位置。
绘制网格
让我们通过一个简单的网格来实践这个。以下函数接受一个箱子的数量 n-bins
和一个 size
参数,size
表示为 [width height]
的向量:
defn draw-grid [{:keys [n-bins size]}]
(let [[width height] size
x-scale (/ width n-bins)
y-scale (/ height n-bins)
setup (fn []
(doseq [x (range n-bins)
y (range n-bins)
:let [x-pos (* x x-scale)
y-pos (- height
(* (inc y) y-scale))]]
(q/rect x-pos y-pos x-scale y-scale)))]
(q/sketch :setup setup :size size)))
从中,我们可以计算出 x-scale
和 y-scale
,这是一个因子,使我们能够将箱子索引转换为 x 和 y 维度中的像素偏移。这些值被我们的 setup
函数使用,setup
函数遍历 x 和 y 箱子,为每个箱子放置一个矩形。
注意
请注意,我们在 doseq
内部执行了循环。我们的绘图指令作为副作用执行。如果我们不这样做,Clojure 的惰性求值将导致没有任何绘制操作。
之前的代码生成了以下图形:
在定义了前面的函数后,我们几乎已经创建了一个直方图。我们只需要为网格中的每个方格着色,颜色代表每个箱子的适当值。为了实现这一点,我们需要两个额外的函数:一个从数据中获取与箱子对应的值,另一个将这些值解释为颜色。
指定填充颜色
在 Quil 中填充颜色是通过 q/fill
函数实现的。我们指定的任何填充将在我们指定新的填充之前一直使用。
注意
Quil 中的许多函数会影响当前的绘图上下文,并且是 有状态的。例如,当我们指定一个填充值时,它将在后续的所有绘图指令中使用,直到填充值被更改。其他例子包括填充、笔触、缩放和字体。
以下代码是我们 draw-grid
函数的一个改编版本。draw-filled-grid
的新增部分是 fill-fn
:用于给网格中的点着色的方式。fill-fn
函数应该是一个接受 x 和 y 箱子索引作为参数的函数,它应返回 Quil 可以用作填充的表示:
(defn draw-filled-grid [{:keys [n-bins size fill-fn]}]
(let [[width height] size
x-scale (/ width n-bins)
y-scale (/ height n-bins)
setup (fn []
(doseq [x (range n-bins)
y (range n-bins)
:let [x-pos (* x x-scale)
y-pos (- height
(* (inc y) y-scale))]]
(q/fill (fill-fn x y))
(q/rect x-pos y-pos x-scale y-scale)))]
(q/sketch :setup setup :size size)))
Quil 的填充函数接受多个参数:
-
一个参数:RGB 值(可以是一个数字或
q/color
表示法) -
两个参数:与单个参数的情况一样,除了加上一个 alpha 透明度值
-
三个参数:颜色的红、绿、蓝分量,作为 0 到 255 之间的数字(包括 0 和 255)
-
四个参数:红色、绿色、蓝色和透明度的数值
我们很快就会看到如何使用 color
表示法,但现在,我们将使用一个简单的数字表示法来表示颜色:介于 0 和 255 之间的数字。当红色、绿色和蓝色的数值相同时(或者当调用 fill
函数时传递一个或两个参数),我们得到一种灰色。0 对应黑色,255 对应白色。
如果我们将每个柱状图中值的频率除以最大值,我们将得到一个介于 0 和 1.0 之间的数字。将其乘以 255 将得到一个 Quil 会转换为灰色的值。我们在以下 fill-fn
实现中做了这个,并将其传递给我们之前定义的 draw-filled-grid
函数:
(defn ex-10-6 []
(let [data (load-data :ru-victors)
n-bins 5
hist (histogram-2d (i/$ :turnout data)
(i/$ :victors-share data)
n-bins)
max-val (apply max (vals hist))
fill-fn (fn [x y]
(-> (get hist [x y] 0)
(/ max-val)
(* 255)))]
(draw-filled-grid {:n-bins n-bins
:size [250 250]
:fill-fn fill-fn})))
前面的代码生成了以下图形:
图表完成了我们想要的功能,但它是我们数据的非常粗略的表示。让我们增加箱子的数量来提高柱状图的分辨率:
(defn ex-10-7 []
(let [data (load-data :ru-victors)
n-bins 25
hist (histogram-2d (i/$ :turnout data)
(i/$ :victors-share data)
n-bins)
max-val (apply max (vals hist))
fill-fn (fn [x y]
(-> (get hist [x y] 0)
(/ max-val)
(* 255)))]
(draw-filled-grid {:n-bins n-bins
:size [250 250]
:fill-fn fill-fn})))
这段代码生成了以下图形:
在 x 和 y 轴上各有 25 个矩形,我们就能获得数据结构的更细粒度的图像。然而,副作用是由于大多数单元格的色调较暗,柱状图的细节变得难以辨认。部分问题在于右上角的值如此之高,以至于即便是中央区域(之前最亮的部分)现在也不过是一个灰色的模糊斑点。
这个问题有两个解决方案:
-
通过绘制 z-分数而不是实际值来减轻离群值的影响
-
通过使用更广泛的颜色范围来多样化视觉线索
我们将在下一节中学习如何将值转换为完整的颜色谱,但首先,让我们将柱状图的值转换为 z-分数。绘制 z-分数是以分布为意识的方式着色图表,这将大大减小右上角极端离群值的影响。使用 z-分数,我们将绘制每个单元格与均值之间的标准差数量。
为了实现这一点,我们需要知道两件事:柱状图中频率的均值和标准差:
(defn ex-10-8 []
(let [data (load-data :ru-victors)
n-bins 25
hist (histogram-2d (i/$ :turnout data)
(i/$ :victors-share data)
n-bins)
mean (s/mean (vals hist))
sd (s/sd (vals hist))
fill-fn (fn [x y]
(-> (get hist [x y] 0)
(- mean)
(/ sd)
(q/map-range -1 3 0 255)))]
(draw-filled-grid {:n-bins n-bins
:size [250 250]
:fill-fn fill-fn})))
前面的代码从柱状图中的每个值中减去均值,然后除以均值。这样会得到一个均值为零的值。1
将代表离均值一个标准差,2
将代表离均值两个标准差,依此类推。
Quil 暴露了一个有用的map-range
函数,它可以将一个值范围映射到另一个值范围。例如,我们可以将所需的标准差范围(在前面的例子中是-1 到 3)映射到 0 到 255 的范围。这样,分布的四个标准差将对应于从黑到白的完整灰度范围。任何超过此范围的数据将被裁剪。
结果是一个更引人注目的灰度数据表示方式。使用z分数使得直方图的主体部分呈现出更多细节,我们现在能够察觉到尾部的更多变化。
然而,尽管如此,直方图仍然没有达到理想的清晰度,因为区分不同的灰度色调可能会很有挑战性。在某些不相邻的单元格之间,可能很难判断它们是否共享相同的值。
我们可以通过利用颜色来表示每个单元格,从而扩展可用的范围。这使得直方图更像热力图:“较冷”的颜色,如蓝色和绿色,代表低值,而“较热”的颜色,如橙色和红色,则代表热力图中最密集的区域。
颜色与填充
为了创建我们二维直方图的热力图版本,我们需要将* z *分数与颜色值进行映射。与显示离散的颜色调色板(例如 5 种颜色)不同,我们的热力图应该有一个*滑的调色板,包含光谱中的所有颜色。
注意
对于那些阅读印刷版书籍或黑白版本的读者,你可以从 Packt 出版社的网站下载彩色图像:www.packtpub.com/sites/default/files/downloads/Clojure_for_Data_Science_ColorImages.pdf
。
这正是 Quil 函数q/lerp-color
所做的。给定两种颜色和介于 0 与 1 之间的比例,lerp-color
将返回两者之间插值的新颜色。0 的比例返回第一个颜色,1 的比例返回第二个颜色,而 0.5 的比例则返回两者之间的颜色:
(defn z-score->heat [z-score]
(let [colors [(q/color 0 0 255) ;; Blue
(q/color 0 255 255) ;; Turquoise
(q/color 0 255 0) ;; Green
(q/color 255 255 0) ;; Yellow
(q/color 255 0 0)] ;; Red
offset (-> (q/map-range z-score -1 3 0 3.999)
(max 0)
(min 3.999))]
(q/lerp-color (nth colors offset)
(nth colors (inc offset))
(rem offset 1))))
这段代码使用了按光谱顺序排列的颜色数组。我们用q/map-range
来确定我们将要插值的两个颜色,并使用q/lerp-color
与范围的小数部分进行插值。
我们已经实现了一个draw-filled-grid
函数,它接受fill-fn
来决定使用哪种颜色填充网格。现在,让我们将z-score->heat
函数传递给它:
(defn ex-10-9 []
(let [data (load-data :ru-victors)
n-bins 25
hist (histogram-2d (i/$ :turnout data)
(i/$ :victors-share data)
n-bins)
mean (s/mean (vals hist))
sd (s/sd (vals hist))
fill-fn (fn [x y]
(-> (get hist [x y] 0)
(- mean)
(/ sd)
(z-score->heat)))]
(draw-filled-grid {:n-bins n-bins
:size [250 250]
:fill-fn fill-fn})))
这段代码生成了以下图形:
热力图展示了数据的更多内部结构。尤其是,虽然数据的强烈对角形状依然显现,但我们现在可以看到更多的变化。先前难以确定的细节(无论是因为区域过于密集还是过于稀疏)变得更加明显。
输出图像文件
现在我们对直方图感到满意,接下来我们希望输出高质量的版本。通过在设置函数中加入q/save
命令,并传入文件名,Quil 将同时将图表输出到文件和屏幕上。图像创建的格式将取决于文件名的后缀:.tif
为 TIFF 文件,.jpg
为 JPEG 文件,.png
为 PNG 文件,.tga
为 TARGA 文件:
(defn draw-filled-grid [{:keys [n-bins size fill-fn]}]
(let [[width height] size
x-scale (/ width n-bins)
y-scale (/ height n-bins)
setup (fn []
(doseq [x (range n-bins)
y (range n-bins)
:let [x-pos (* x x-scale)
y-pos (- height
(* (inc y) y-scale))]]
(q/fill (fill-fn x y))
(q/rect x-pos y-pos x-scale y-scale))
(q/save "heatmap.png"))]
(q/sketch :setup setup :size size)))
我们还可以将输出结果保存为 PDF 格式,正如接下来的可视化展示。
用于沟通的可视化
作为数据科学家,在工作中我们可能需要与各种各样的人沟通。我们身边的同事和经理可能能够阅读并解读我们的 Incanter 图表,但这不会给 CEO 留下深刻印象。我们可能还需要与公众沟通。
无论是哪种情况,我们都应该专注于制作简单且有力的可视化图表,同时又不牺牲数据的完整性。缺乏统计学训练并不妨碍我们理解那些微妙而复杂的论点,我们应该尊重观众的智慧。作为数据科学家,我们面临的挑战是找到一种有效地传达信息的表现形式。
在本章的剩余部分,我们将制作一个可视化图表,旨在以简洁而真实的方式传达更复杂的数据集。
注意
我们将创建的可视化图表是来自于美国财富不*等视频中的一个图表版本,视频可以在www.youtube.com/watch?v=QPKKQnijnsM
观看。该视频由匿名电影制作人 Politizane 制作,强有力的视觉冲击使其在 YouTube 上获得了超过 1600 万次点击。
像这样的图形展示常常有一个问题,我们的数据将来自多个不同的来源。
可视化财富分布
我们将使用的第一个数据集来自加利福尼亚大学圣克鲁兹分校的心理学与社会学研究教授 G. William Domhoff 的一篇文章。我们接下来引用的数字来自一篇名为财富、收入与权力的文章,地址为www2.ucsc.edu/whorulesamerica/power/wealth.html
。
虽然整篇文章都值得阅读,但其中一张特别引人注目的图表是一张饼图,展示了 2010 年美国人财务净资产的分布:
这张饼图之所以引人注目,有几个原因。首先,40%以上的财富掌握在如此小的群体手中,这一概念令人难以理解。其次,饼图的每一块不仅代表着截然不同的财富数量,还代表着截然不同的人群数量:从占人口 1%的群体到占人口 80%的群体。饼图本就因阅读困难而著名,因此这张图表的挑战性更大。
注意
饼图通常不是表示数据的好方式,即使总数在概念上代表整体的一部分。作者兼程序员 Steve Fenton 已经记录了许多原因,并提供了适当的替代方法,详见www.stevefenton.co.uk/2009/04/pie-charts-are-bad/
。
让我们看看如何重新解读这些数据,让它更易于理解。首先,提取出以下表格中所列的数字:
百分位 | 总金融财富,2010 |
---|---|
0-79 | 5% |
80-89 | 11% |
90-95 | 13% |
96-99 | 30% |
100 | 42% |
相较于饼图,改进的小方法是将相同的数据表示为条形图。虽然人们通常很难成功地解读饼图中各个扇形的相对大小,条形图则不会遇到此类问题。下一个示例将简单地将之前的数据转换为条形图:
(defn ex-10-10 []
(let [categories ["0-79" "80-89" "90-95" "96-99" "100"]
percentage [5 11 13 30 42 ]]
(-> (c/bar-chart categories percentage
:x-label "Category"
:y-label "% Financial Wealth")
(i/view))))
这将返回以下图表:
这是对饼图的一种改进,因为它更容易比较各类别的相对大小。然而,仍然存在一个显著的问题:每个类别所代表的人数差异巨大。左侧的条形代表了 80%的人口,而右侧的条形则代表了 1%的人口。
如果我们想让这些数据更加易于理解,我们可以将总量划分为 100 个相等的单位,每个单位代表人口的一个百分位。每个条形的宽度可以根据其所代表的百分位数进行调整,同时保持其面积不变。由于每个百分位单位代表相同数量的人口,因此生成的图表可以让我们更容易地进行不同群体间的比较。
我们可以通过返回一个包含 100 个元素的序列来实现这一目标,每个元素代表一个人口百分位。序列中每个元素的值将是该百分位所占的财富比例。我们已经知道,前 1%的人口拥有 42%的财富,但其他群体将根据它们所代表的百分位数调整其值:
(def wealth-distribution
(concat (repeat 80 (/ 5 80))
(repeat 10 (/ 11 10))
(repeat 5 (/ 13 5))
(repeat 4 (/ 30 4))
(repeat 1 (/ 42 1))))
(defn ex-10-11 []
(let [categories (range (count wealth-distribution))]
(-> (c/bar-chart categories wealth-distribution
:x-label "Percentile"
:y-label "% Financial Wealth")
(i/view))))
这个示例生成了以下的条形图:
通过应用一个简单的转换,我们能够更好地理解真正的分布情况。每个条形现在代表着相同比例的人口,条形的面积代表该百分位所拥有的财富比例。
使用 Quil 使数据更生动
前一部分的变换结果是一个几乎过于直观显示极端值差异的图表:除了最大条形外,几乎无法解读任何内容。一种解决方案是使用对数刻度或对数-对数刻度来显示数字,正如我们在本书的其他地方所做的那样。如果这个图表的观众是统计学上有素养的人,那么这样做可能是最合适的,但我们假设我们的可视化面向的目标观众是普通大众。
之前展示的图表的问题是最右侧的条形图过大,压倒了其他所有条形。80%的面积仅由几个像素表示。在下一部分中,我们将利用 Quil 制作一个更好地利用空间,同时又能保持图表完整性的可视化。
绘制不同宽度的条形图
在接下来的几个部分中,我们将分阶段构建一个可视化。由于我们将绘制一个 Quil 草图,我们首先会定义一些常数,以便相对于草图的尺寸生成绘图指令。为了简洁起见,下一段代码省略了一些常数:
(def plot-x 56)
(def plot-y 60)
(def plot-width 757)
(def plot-height 400)
(def bar-width 7)
在这些完成后,我们可以开始以更易于理解的方式呈现条形图。以下代码采用财富分布并将除了最后一条条形以外的所有条形绘制为一系列矩形。y轴的刻度被计算出来,以便我们绘制的最大条形图填满整个绘图的高度:
(defn draw-bars []
(let [pc99 (vec (butlast wealth-distribution))
pc1 (last wealth-distribution)
y-max (apply max pc99)
y-scale (fn [x] (* (/ x y-max) plot-height))
offset (fn [i] (* (quot i 10) 7))]
(dotimes [i 99] ;; Draw the 99%
(let [bar-height (y-scale (nth pc99 i))]
(q/rect (+ plot-x (* i bar-width) (offset i))
(+ plot-y (- plot-height bar-height))
bar-width bar-height)))
(let [n-bars 5 ;; Draw the 1%
bar-height (y-scale (/ pc1 n-bars))]
(q/rect (+ plot-x (* 100 bar-width) (offset 100))
(+ plot-y (- plot-height bar-height))
(* bar-width n-bars) bar-height))))
到目前为止,我们绘制的条形图代表了 99%的比例。最后一条条形将代表人口的最后 1%。为了使其适应我们设计的垂直刻度,且不至于从草图的顶部消失,我们将相应地加宽该条形,同时保持其面积。因此,这条条形比其他条形短 5 倍——但也宽 5 倍:
(defn ex-10-12 []
(let [size [960 540]]
(q/sketch :size size
:setup draw-bars)))
示例输出如下图:
到目前为止,我们已经能够更清晰地看到最大条形之间的关系,但还不明显能看出这是一个图表。在接下来的部分中,我们将添加文本,以标明图表的主题和坐标轴的范围。
添加标题和坐标轴标签
专用可视化工具(如 Incanter)的一大便利之处是,坐标轴可以自动生成。Quil 在这里没有提供帮助,但由于条形宽度已知,因此我们并不难实现。在下面的代码中,我们将使用text
、text-align
、text-size
等函数将文本写入我们的可视化中:
(defn group-offset [i]
(* (quot i 10) 7))
(defn draw-axis-labels []
(q/fill 0)
(q/text-align :left)
(q/text-size 12)
(doseq [pc (range 0 (inc 100) 10)
:let [offset (group-offset pc)
x (* pc bar-width)]]
(q/text (str pc "%") (+ plot-x x offset) label-y))
(q/text "\"The 1%\"" pc1-label-x pc1-label-y))
使用非专业图表库所失去的,我们在灵活性上得到了补偿。接下来,我们将编写一个函数,在文本上制作凸印风格的压印效果:
(defn emboss-text [text x y]
(q/fill 255)
(q/text text x y)
(q/fill 100)
(q/text text x (- y 2)))
(defn draw-title []
(q/text-size 35)
(q/text-leading 35)
(q/text-align :center :top)
(emboss-text "ACTUAL DISTRIBUTION\nOF WEALTH IN THE US"
title-x title-y))
我们使用emboss-text
函数在图表的中心绘制一个大标题。注意,我们还指定了文本的对齐方式,可以选择从文本的顶部、底部、中心、左侧或右侧来测量位置:
(defn ex-10-13 []
(let [size [960 540]]
(q/sketch :size size
:setup #((draw-bars)
(draw-axis-labels)
(draw-title)))))
之前的例子生成了以下图形:
这个图表结合了条形图高度、面积和自定义文本可视化,使用标准图表应用程序很难实现。使用 Quil,我们有一个工具箱,可以轻松地将图形和数据自由混合。
通过插图提高清晰度
我们的图表已经有了一些进展,但目前看起来非常简洁。为了增加更多的视觉吸引力,可以使用图像。在示例项目的资源目录中,有两个 SVG 图像文件,一个是人形图标,另一个是来自维基百科的美国地图。
注意
维基百科提供了各种各样的 SVG 地图,这些地图都可以在灵活的创作共享许可证下使用。例如,commons.wikimedia.org/wiki/Category:SVG_maps_of_the_United_States
上的美国地图。
我们在本章使用的地图可以在commons.wikimedia.org/wiki/File:Blank_US_Map,_Mainland_with_no_States.svg
找到,由 Lokal_Profil 在 CC-BY-SA-2.5 许可证下提供。
在 Quil 中使用 SVG 图像是一个两步过程。首先,我们需要使用q/load-shape
将图像加载到内存中。此函数接受一个参数:要加载的 SVG 文件的路径。接下来,我们需要将图像实际绘制到屏幕上。这是通过使用q/shape
函数来完成的,该函数需要一个* x , y *位置以及可选的宽度和高度。如果我们使用的是基于像素的图像(如 JPEG 或 PNG),我们将使用相应的q/load-image
和q/image
函数:
(defn draw-shapes []
(let [usa (q/load-shape "resources/us-mainland.svg")
person (q/load-shape "resources/person.svg")
colors [(q/color 243 195 73)
(q/color 231 119 46)
(q/color 77 180 180)
(q/color 231 74 69)
(q/color 61 76 83)]]
(.disableStyle usa)
(.disableStyle person)
(q/stroke 0 50)
(q/fill 200)
(q/shape usa 0 0)
(dotimes [n 99]
(let [quintile (quot n 20)
x (-> (* n bar-width)
(+ plot-x)
(+ (group-offset n)))]
(q/fill (nth colors quintile))
(q/shape person x icons-y icon-width icon-height)))
(q/shape person
(+ plot-x (* 100 bar-width) (group-offset 100))
icons-y icon-width icon-height)))
在这段代码中,我们对usa
和person
形状都调用了.disableStyle
。这是因为 SVG 文件可能包含嵌入的样式信息,比如填充颜色、描边颜色或边框宽度,这些都会影响 Quil 绘制形状的方式。为了完全控制我们的表现形式,我们选择禁用所有样式。
此外,请注意,我们只加载一次 person 形状,并通过dotimes
绘制多次。我们根据用户所处的quintile
设置颜色:
(defn ex-10-14 []
(let [size [960 540]]
(q/sketch :size size
:setup #((draw-shapes)
(draw-bars)
(draw-axis-labels)
(draw-title)))))
结果如下一张图所示:
这张图已经开始看起来像我们可以展示给人们而不感到羞愧的那种。人物图标帮助传达了每个条形代表一个人口百分位的概念。条形图现在看起来还不太吸引人。由于每个条形代表每个人的财富,所以我们把每个条形画成一叠钞票。虽然这可能看起来是一个过于字面化的解释,但实际上会更清晰地传达出 1%的条形实际上是其他所有条形的 5 倍宽。
向条形图中添加文字
到现在为止,我们已经可以将钞票画成一系列矩形了:
(defn banknotes [x y width height]
(q/no-stroke)
(q/fill 80 127 64)
(doseq [y (range (* 3 (quot y 3)) (+ y height) 3)
x (range x (+ x width) 7)]
(q/rect x y 6 2)))
上述代码的唯一复杂之处在于需要将起始的y位置调整为3
的偶数倍。这将确保所有的钞票在y轴上的高度如何,最终都能与x轴对齐。这是从上到下绘制条形的副作用,而不是反向绘制。
我们将在以下示例中将之前的函数添加到我们的草图中:
(defn ex-10-15 []
(let [size [960 540]]
(q/sketch :size size
:setup #((draw-shapes)
(draw-banknotes)
(draw-axis-labels)
(draw-title)))))
这将生成以下图表:
现在这已经是一个相对完整的图表,代表了美国实际的财富分布。之前提供的 YouTube 视频链接的一个优点是,它将实际分布与几种其他分布进行了对比:人们预期的财富分布和他们理想的财富分布。
融入额外数据
哈佛商学院教授迈克尔·诺顿和行为经济学家丹·阿里利对 5000 多名美国人进行了研究,评估他们对财富分配的看法。当他们展示了各种财富分配的例子,并让他们判断哪个例子来自美国时,大多数人选择了一个比实际情况更加*衡的分布。当被问及理想的财富分配时,92%的人选择了一个更加公*的分配方式。
以下图形展示了本研究的结果:
前面的图形由 Mother Jones 发布,来源为www.motherjones.com/politics/2011/02/income-inequality-in-america-chart-graph
,数据来源于www.people.hbs.edu/mnorton/norton%20ariely%20in%20press.pdf
。
之前的图表很好地展示了人们对每个五分位的财富分布的感知与现实之间的相对差异。我们将把这些数据转化为一种替代表示方式,就像之前一样,我们可以将这些数据转化为表格表示。
从之前的图表和参考链接的论文中,我得到了以下按五分位划分的大致数据:
五分位 | 理想百分比 | 预期百分比 | 实际百分比 |
---|---|---|---|
100th | 32.0% | 58.5% | 84.5% |
80th | 22.0% | 20.0% | 11.5% |
60th | 21.5% | 12.0% | 3.7% |
40th | 14.0% | 6.5% | 0.2% |
20th | 10.5% | 3.0% | 0.1% |
让我们取理想和预期的分布,并找到一种方法将它们绘制在我们现有的财富分布图上。我们的柱状图已经表示了不同百分位的相对财富,作为面积的大小。为了使两个数据集可比,我们也应该对这些数据做相同的处理。之前的表格通过已经将数据表示为五个大小相等的组,帮助我们简化了处理,因此我们无需像处理饼图数据时那样应用转换。
然而,让我们利用这个机会,进一步了解在 Quil 中绘制复杂形状,看看能否得到如下所示的数据展示:
该表格提供了我们要通过标签为A、B和C的形状表示的相对面积。为了绘制先前的形状,我们必须计算出高度x、y和z。这些将为我们提供可在图表上绘制的坐标。
区域A、B和C的宽度是w。因此,x和w的乘积将等于A的面积:
由此可得,x的高度仅为A的面积除以w。Y则稍微复杂一点,但也不算太复杂。B的三角形部分的面积等于:
因此:
我们可以通过相同的方式计算z:
扩展我们的定义,得到以下z的方程:
如果我们假设w为 1(所有我们的五分位宽度相等),那么我们可以得到以下方程,适用于任意数量的区间:
这可以通过一个简单的递归函数来表示。我们的第一个比例将被赋值为x。随后可以通过以下方式计算其他值:
(defn area-points [proportions]
(let [f (fn [prev area]
(-> (- area prev)
(* 2)
(+ prev)))
sum (reduce + proportions)]
(->> (reductions f (first proportions) proportions)
(map #(/ % sum)))))
reductions
函数的行为与reduce
完全相同,但会保留我们计算的中间步骤。我们不会只得到一个值,而是会得到一个值序列,对应于我们y-坐标的(比例)高度。
绘制复杂形状
前面定义的area-points
函数将为我们提供一系列点以便绘制。然而,我们还没有涉及 Quil 中的函数,这些函数将允许我们绘制它们。要绘制直线,我们可以使用q/line
函数。线函数将接受起点和终点坐标,并在它们之间绘制一条直线。我们可以通过这种方式构建一个面积图,但它没有填充。线条仅描述轮廓,我们不能像使用q/rect
绘制直方图时那样构建带有填充色的形状。为了给形状填充颜色,我们需要一个一个顶点地构建它们。
要用 Quil 构建任意复杂的形状,首先调用q/begin-shape
。这是一个有状态的函数,它让 Quil 知道我们想开始构建一系列顶点。随后调用的q/vertex
将与我们正在构建的形状关联。最后,调用q/end-shape
将完成形状的构建。我们将根据当前绘图上下文中指定的描边和填充样式来绘制它。
让我们通过绘制一些使用上一节定义的area-points
函数的测试形状,看看它是如何工作的:
(defn plot-area [proportions px py width height]
(let [ys (area-points proportions)
points (map vector (range) ys)
x-scale (/ width (dec (count ys)))
y-scale (/ height (apply max ys))]
(q/stroke 0)
(q/fill 200)
(q/begin-shape)
(doseq [[x y] points]
(q/vertex (+ px (* x x-scale))
(- py (* y y-scale))))
(q/end-shape)))
(defn ex-10-16 []
(let [expected [3 6.5 12 20 58.5]
width 640
height 480
setup (fn []
(q/background 255)
(plot-area expected 0 height width height))]
(q/sketch :setup setup :size [width height])))
这个示例使用之前定义的area-points
函数绘制了[3 6.5 12 20 58.5]
序列。这是美国“预期”财富分布数据表中列出的百分比值序列。plot-area
函数会调用begin-shape
,遍历由area-points
返回的*ys*
序列,并调用end-shape
。结果如下:
这还不是我们想要的效果。虽然我们要求填充形状,但我们并没有描述完整的待填充形状。Quil 不知道我们想如何封闭形状,因此它只是从最后一个点画到第一个点,横跨了我们图表的对角线。幸运的是,这个问题可以通过确保图表的底部两个角都有点来轻松解决:
(defn plot-full-area [proportions px py width height]
(let [ys (area-points proportions)
points (map vector (range) ys)
x-scale (/ width (dec (count ys)))
y-scale (/ height (apply max ys))]
(q/stroke 0)
(q/fill 200)
(q/begin-shape)
(q/vertex 0 height)
(doseq [[x y] points]
(q/vertex (+ px (* x x-scale))
(- py (* y y-scale))))
(q/vertex width height)
(q/end-shape)))
(defn ex-10-17 []
(let [expected [3 6.5 12 20 58.5]
width 640
height 480
setup (fn []
(q/background 255)
(plot-full-area expected 0 height width height))]
(q/sketch :setup setup :size [width height])))
plot-full-area
函数在遍历*ys*
序列之前和之后都会多次调用vertex
。这些指定的点确保在调用end-shape
之前,形状已完全描述。结果如下图所示:
这已经好多了,开始看起来像一个面积图了。在下一节中,我们将讨论如何使用曲线来描述更复杂的形状。虽然曲线不是我们面积图的必需元素,但它会让结果看起来更有吸引力。
绘制曲线
面积图看起来不错,但我们可以通过使用 Quil 的样条曲线来去除那些尖锐的角。与其通过添加顶点来构建形状,我们可以调用q/curve-vertex
来*滑边缘之间的连接。
q/curve-vertex
函数实现了一种已知的曲线绘制方法,叫做 Catmull-Rom 样条曲线。要绘制一条曲线,我们必须至少指定四个顶点:第一个和最后一个顶点将作为控制点,曲线将在中间的两个顶点之间绘制。
我们通过下面的图示来可视化 Catmull-Rom 样条曲线的工作原理,该图显示了由点a、b、c和d指定的路径:
在c点处的曲线切线与X*行:由a和b两点所描述的直线;在b点处的曲线切线与Y*行:由c和d两点所描述的直线。因此,要绘制一条曲线,我们需要确保在曲线的起点和终点添加这些额外的控制点。每个控制点都通过curve-vertex
添加,我们会在迭代点之前先调用一次,然后在结束时再调用一次:
(defn smooth-curve [xs ys]
(let [points (map vector xs ys)]
(apply q/curve-vertex (first points))
(doseq [point points]
(apply q/curve-vertex point))
(apply q/curve-vertex (last points))))
现在我们已经定义了一个smooth-curve
函数,我们将在接下来的两个函数中使用它,分别是smooth-stroke
和smooth-area
:
(defn smooth-stroke [xs ys]
(q/begin-shape)
(q/vertex (first xs) (first ys))
(smooth-curve (rest xs) (rest ys))
(q/end-shape))
(defn smooth-area [xs ys]
(q/begin-shape)
(q/vertex (first xs) (first ys))
(smooth-curve (rest xs) (rest ys))
(q/vertex (last xs) (first ys))
(q/end-shape))
smooth-stroke
函数将通过为每个xs和ys创建顶点来绘制定义的形状。smooth-area
函数通过闭合形状并避免之前看到的填充与形状对角线交叉的情况,扩展了这一点。将这两个函数结合起来的是plot-curve
,该函数接受要绘制的xs和ys,以及用于绘制的填充颜色、边框颜色和边框粗细:
(defn plot-curve [xs ys fill-color
stroke-color stroke-weight]
(let [points (map vector xs ys)]
(q/no-stroke)
(q/fill fill-color)
(smooth-area xs ys)
(q/no-fill)
(q/stroke stroke-color)
(q/stroke-weight stroke-weight)
(smooth-stroke xs ys)))
让我们在之前绘制的相同预期值序列上调用plot-curve
函数,并比较差异:
(defn plot-smooth-area [proportions px py width height]
(let [ys (cons 0 (area-points proportions))
points (map vector (range) ys)
x-scale (/ width (dec (count ys)))
y-scale (/ height (apply max ys) -1)]
(plot-curve (map (point->px px x-scale) (range (count ys)))
(map (point->px py y-scale) ys)
(q/color 200)
(q/color 0) 2)))
(defn ex-10-18 []
(let [expected [3 6.5 12 20 58.5]
width 640
height 480
setup (fn []
(q/background 255)
(plot-smooth-area expected 0 height
width height))]
(q/sketch :setup setup :size [width height])))
这个示例生成了如下图像:
曲线的效果微妙,但它为我们的图表提供了润色,否则图表会显得不完整。前面的图表展示了 Norton 和 Ariely 研究中预期的财富分布。在将其与之前创建的实际财富分布图结合之前,让我们看看它如何与同一研究中的理想财富分布结合。
绘制复合图表
之前的描述展示了如何创建一个适应区域的单一曲线图。正如我们定义的那样,plot-smooth-area
函数将在我们绘制的每个区域中填充指定的高度。从绘图的角度看,这样做是合理的,但在尝试绘制两个可比较图表时,这就不太合适了:我们需要确保它们使用相同的缩放比例。
在接下来的代码块中,我们将根据两个图形中较大的一个来计算缩放比例,然后使用该比例绘制两个图形。这样可以确保我们绘制的所有系列彼此之间可以进行比较。组合图表将填满我们分配给它的宽度和高度:
(defn plot-areas [series px py width height]
(let [series-ys (map area-points series)
n-points (count (first series-ys))
x-scale (point->px px (/ width (dec n-points)))
xs (map x-scale (range n-points))
y-max (apply max (apply concat series-ys))
y-scale (point->px py (/ height y-max -1))]
(doseq [ys series-ys]
(plot-curve (cons (first xs) xs)
(map y-scale (cons 0 ys))
(q/color 255 100)
(q/color 255 200) 3))))
(defn ex-10-19 []
(let [expected [3 6.5 12 20 58.5]
ideal [10.5 14 21.5 22 32]
width 640
height 480
setup (fn []
(q/background 100)
(plot-areas [expected ideal] 0 height
width height))]
(q/sketch :setup setup :size [width height])))
我们使用 plot-areas
函数绘制了 expected
和 ideal
两个系列,且通过 background
函数将背景设为更深的颜色。在调用 plot-curve
时,我们指定使用半透明的白色作为填充颜色。下图展示了结果:
为了将这个图表与之前创建的实际数据图表结合,我们只需要调整它的尺度以便匹配。此图表右上角的最高点对应于 5% 的概率密度。我们实际图表上的 96-99^(th) 百分位代表总量的 7.5%,各自对应其图表。这意味着我们需要将之前的图表绘制为现有图表的 2/3 高度,才能使坐标轴可比。现在我们来做这个,并在此过程中为两个新系列添加标签:
(defn draw-expected-ideal []
(let [expected [3 6.5 12 20 58.5]
ideal [10.5 14 21.5 22 32]]
(plot-areas [expected ideal]
plot-x
(+ plot-y plot-height)
plot-width
(* (/ plot-height 0.075) 0.05))
(q/text-size 20)
(emboss-text "EXPECTED" 400 430)
(emboss-text "IDEAL" 250 430)))
最后,我们调用草图中的 draw-expected-ideal
函数以及之前定义的其他函数:
(defn ex-10-20 []
(let [size [960 540]]
(q/sketch :size size
:setup #((draw-shapes)
(draw-expected-ideal)
(draw-banknotes)
(draw-axis-labels)
(draw-title)))))
完成的结果在下图中展示:
希望你会同意,最终的图表既美观又富有信息性。最重要的是,我们是通过实际数据的绘图指令来生成这张图表。如果这张图表是手工制作的,其完整性将难以建立。
输出为 PDF
所有元素组合在一起,最终形成了可能被打印出来的图形。我们提供的绘图指令是基于矢量的——而非基于像素的——因此它将按需要的任何分辨率进行缩放而不失真。
与我们用 save
方法将直方图输出为基于像素的格式不同,我们将输出为 PDF 格式。PDF 格式能够保持我们的艺术作品的可扩展性,并且允许我们在任何所需的分辨率下输出。为此,我们通过传递 :pdf
关键字以及 :output-file
路径来配置草图以使用 PDF 渲染器。
(defn ex-10-21 []
(let [size [960 540]]
(q/sketch :size size
:setup #((draw-shapes)
(draw-expected-ideal)
(draw-banknotes)
(draw-axis-labels)
(draw-title))
:renderer :pdf
:output-file "wealth-distribution.pdf")))
最终的示例将把完成的 PDF 文件输出到项目目录的根目录。
总结
在本章中,我们看到通过使用简单的可视化—仅使用彩色矩形—如何从数据中获得有价值的洞察,并且如何结合 Clojure 核心函数与 Quil 的绘图 API,使我们能够生成传递信息的强大图形。
我们通过 Quil 库实现了这一切。Quil 的功能远不止我们在此展示的内容:它支持交互式动画,支持 ClojureScript 输出到网页,并且还可以进行 3D 渲染。可视化是一个庞大的主题,我们在本章中只能通过一些示例来激发你对它的兴趣。通过展示即使是使用矩形、曲线和 SVG 的基础绘图指令如何组合成强大的图形,我们希望能激发你创造自定义可视化的可能性。
这是《Clojure 数据科学》的最后一章。请务必访问本书的网站 clojuredatascience.com
,获取更多关于所涵盖主题的信息和进一步阅读的链接。我们打算为数据科学家,尤其是 Clojure 程序员,提供一个持续更新的资源。
使用一种库也在迅速发展的编程语言,来传达数据科学这个多样且迅速发展的领域的广度和深度,确实是一个雄心勃勃的任务。尽管如此,我们希望《Clojure 数据科学》能让你对统计学、机器学习和大数据处理的一些基本概念有所了解。这些概念基础应该能为你提供帮助,即使技术选项——甚至可能是你的编程语言选择——未来还会继续演变。