Julia-数据分析-全-
Julia 数据分析(全)
原文:Julia for Data Analysis
译者:飞龙
前言
前言
现在,世界上充斥着大量的数据分析软件工具。读者可能会想,为什么是 Julia for Data Analysis?这本书回答了“为什么”和“如何”的问题。
由于读者可能不熟悉我,我想自我介绍一下。我是 Julia 语言的创造者之一,同时也是 Julia Computing 的联合创始人和首席执行官。我们创立 Julia 语言的想法很简单——创建一种既像 C 语言一样快速,又像 R 和 Python 一样容易使用的语言。这个简单的想法在许多不同的领域产生了巨大的影响,因为 Julia 社区围绕它建立了一套精彩的抽象和基础设施。Bogumił 与许多共同贡献者一起,为数据分析构建了一个高性能且易于使用的包生态系统。
现在,你可能想知道,为什么还需要另一个库?Julia 的数据分析生态系统是从底层构建的,利用了 Julia 本身的一些基本思想。这些库是“完全 Julia”,意味着它们完全用 Julia 实现——用于处理数据的 DataFrames.jl 库、用于读取数据的 CSV.jl 库、用于统计分析的 JuliaStats 生态系统等等。这些库基于在 R 中开发的思想并进一步发展。例如,在 Julia 中处理缺失数据的基础设施是 Julia 生态系统的一个核心部分。它花费了多年时间才得到正确处理,并使 Julia 编译器高效,以减少处理缺失数据的开销。完全本地的 DataFrames.jl 库意味着你不再需要局限于矢量化编码风格来实现高性能数据分析。你可以简单地编写针对多吉字节数据集的 for 循环,使用多线程进行并行数据处理,与 Julia 生态系统中的计算库集成,甚至将这些作为 Web API 部署,供其他系统使用。本书中展示了所有这些功能。我在这本书中真正喜欢的一点是,Bogumił 向读者介绍的一些例子不仅整洁、小巧、表格化,而且是真实世界的数据——例如,一个包含 200 万行的棋盘游戏集!
本书分为两部分。第一部分介绍了 Julia 语言的基礎概念,包括类型系统、多重分派、数据结构等。第二部分在此基础上进一步阐述,并介绍数据分析——读取数据、选择、创建 DataFrame、分割-应用-组合、排序、连接和重塑,最后以一个完整的应用程序结束。书中还讨论了 Arrow 数据交换格式,它允许 Julia 程序与 R、Python 和 Spark 等数据分析工具共存。书中所有章节中的代码模式都教授读者良好的实践,这些实践有助于实现高性能数据分析。
Bogumił 不仅是对 Julia 数据分析和统计生态系统的重大贡献者,而且还构建了几个课程(如 JuliaAcademy 上的课程)并广泛地博客关于这些包的内部结构。因此,他是介绍 Julia 如何有效用于数据分析的最佳作者之一。
——Viral Shah,Julia Computing 的联合创始人兼首席执行官
前言
我从 2014 年开始使用 Julia 语言。在此之前,我主要使用 R 进行数据分析(那时 Python 在该领域还不够成熟)。然而,除了探索数据和构建机器学习模型之外,我经常需要实现定制的计算密集型代码,这需要花费数天时间来完成计算。我主要使用 C 或 Java 来处理这类应用。不断在编程语言之间切换是一件痛苦的事情。
在我了解到 Julia 之后,我立刻感觉到它是一项符合我需求、令人兴奋的技术。即使在它的早期阶段(在 1.0 版本发布之前),我也能成功地在我的项目中使用它。然而,就像每一样新工具一样,它仍然需要被完善。
然后,我决定开始为 Julia 语言及其与数据管理功能相关的包做出贡献。多年来,我的关注点发生了变化,最终我成为了 DataFrames.jl 包的主要维护者之一。我相信 Julia 现在已经准备好用于严肃的应用,DataFrames.jl 已经达到了稳定状态,并且功能丰富。因此,我决定写这本书,分享我使用 Julia 进行数据分析的经验。
我一直认为,软件不仅要提供出色的功能,还要提供足够的文档。因此,多年来我一直在维护这些在线资源:Julia 快车 (github.com/bkamins/The-Julia-Express),这是一份关于 Julia 语言的快速入门教程;DataFrames.jl 简介 (github.com/bkamins/Julia-DataFrames-Tutorial),一组 Jupyter 笔记本集合;以及关于 Julia 的每周博客 (bkamins.github.io/)。此外,去年 Manning 邀请我准备 Hands-On Data Science with Julia liveProject (www.manning.com/liveprojectseries/data-science-with-julia-ser),这是一套涵盖常见数据科学任务的练习。
在编写了所有这些教学材料之后,我强烈感觉到拼图中还缺少一块。那些想要用 Julia 开始进行数据科学的人很难找到一本能够逐步介绍他们所需基础知识的书籍,以便使用 Julia 进行数据分析。这本书填补了这一空白。
Julia 生态系统有数百个包可用于您的数据科学项目,并且每天都有新的包被注册。这本书的目标是教授 Julia 的最重要的特性和一些用户在进行数据分析时会发现有用的精选流行包。在阅读本书后,您应该能够独立完成以下任务:
-
使用 Julia 进行数据分析。
-
学习由专业包提供的功能,这些功能超越了数据分析,并在进行数据科学项目时很有用。附录 C 提供了我推荐的 Julia 生态系统中的工具概述,按应用领域分类。
-
舒适地学习 Julia 的更高级方面,这些方面对包开发者来说相关重要。
-
从社交媒体上关于 Julia 的讨论中受益,例如 Discourse (
discourse.julialang.org/)、Slack (julialang.org/slack/) 和 Zulip (https://julialang.zulipchat.com/register/)),自信地理解其他用户在评论中引用的关键概念和术语。
致谢
这本书是我与 Julia 语言旅程的重要部分。因此,我想感谢许多帮助过我的人。
让我先感谢那些我从他们那里学到很多并从中获得灵感的 Julia 社区成员。他们的名字太多,难以一一列举,所以我不得不艰难地选择其中几位。在我早期,Stefan Karpinski 在我支持他塑造 Julia 字符串处理功能时,帮助我很多,让我作为 Julia 贡献者入门。在数据科学生态系统中,Milan Bouchet-Valat 已经是我多年的重要合作伙伴。他对 Julia 数据和统计生态系统的维护工作是无价的。我从他那里学到最重要的东西是对细节的关注以及考虑包维护者做出的设计决策的长期后果。下一个关键人物是 Jacob Quinn,他设计了并实现了我在本书中讨论的许多功能。最后,我想提到 Peter Deffebach 和 Frames Catherine White,他们都是 Julia 数据分析生态系统的重大贡献者,并且总是愿意从包用户的视角提供宝贵的评论和建议。
我还想感谢我的编辑 Marina Michaels,技术编辑 Chad Scherrer,以及技术校对 German Gonzalez-Morris,以及那些在本书开发的不同阶段花时间阅读我的手稿并提供宝贵反馈的审稿人:Ben McNamara,Carlos Aya-Moreno,Clemens Baader,David Cronkite,Dr. Mike Williams,Floris Bouchot,Guillaume Alleon,Joel Holmes,Jose Luis Manners,Kai Gellien,Kay Engelhardt,Kevin Cheung,Laud Bentil,Marco Carnini,Marvin Schwarze,Mattia Di Gangi,Maureen Metzger,Maxim Volgin,Milan Mulji,Neumann Chew,Nikos Tzortzis Kanakaris,Nitin Gode,Orlando Méndez Morales,Patrice Maldague,Patrick Goetz,Peter Henstock,Rafael Guerra,Samuel Bosch,Satej Kumar Sahu,Shiroshica Kulatilake,Sonja Krause-Harder,Stefan Pinnow,Steve Rogers,Tom Heiman,Tony Dubitsky,Wei Luo,Wolf Thomsen,以及 Yongming Han。最后,感谢整个 Manning 团队,他们在本书的生产和推广过程中与我合作:Deirdre Hiam,我的项目经理;Sharon Wilkey,我的校对编辑;以及 Melody Dolab,我的页面校对员。
最后,我想对我的科学合作者表示感激,特别是 Tomasz Olczak,Paweł Prałat,Przemysław Szufel,以及 François Théberge,我们共同使用 Julia 语言发表了多篇论文。
关于本书
本书分为两部分,旨在帮助你开始使用 Julia 进行数据分析。它首先解释了 Julia 在此类应用中最重要的一些特性。接下来,它讨论了在数据科学项目中使用的选定核心包的功能。
这份材料围绕完整的数据分析项目构建,从数据收集开始,经过数据转换,最终以可视化和构建基本预测模型结束。我的目标是教会你在任何数据科学项目中都有用的基本概念和技能。
本书不需要你具备高级机器学习算法的先验知识。这些知识对于理解 Julia 中数据分析的基础并不必要,而且我在本书中也没有讨论此类模型。我假设你了解基本的数据科学工具和技术,例如广义线性回归或 LOESS 回归。同样,从数据工程的角度来看,我涵盖了最常用的操作,包括从网络获取数据、编写网络服务、处理压缩文件和使用基本的数据存储格式。我排除了需要额外复杂配置(与 Julia 无关)或专业软件工程知识的特性。
附录 C 回顾了在数据工程和数据分析领域提供高级功能的 Julia 包。通过本书中你获得的知识,你应该能够自信地独立学习使用这些包。
应该阅读本书的人
本书面向希望了解如何使用 Julia 进行数据分析的数据科学家或数据工程师。我假设你有一些使用 R、Python 或 MATLAB 等编程语言进行数据分析的经验。
本书是如何组织的:路线图
本书分为两部分,共有 14 章和三个附录。
第一章概述了 Julia,并解释了为什么它是数据科学项目的优秀语言。
第一部分的章节如下,教授你在数据分析项目中非常有用的基本 Julia 技能。这些章节对于不太熟悉 Julia 语言的读者来说是必不可少的。然而,我预计即使是使用 Julia 的人也会在这里找到有用的信息,因为我选择讨论的主题是基于常见困难问题的。这部分的目的不是提供一个完整的 Julia 语言介绍,而是从数据科学项目的实用性角度来编写的。第一部分的章节包括:
-
第二章介绍了 Julia 的语法基础和常见的语言结构,以及变量作用域规则最重要的方面。
-
第三章介绍了 Julia 的类型系统和方法。它还介绍了如何使用包和模块,最后讨论了宏的使用。
-
第四章涵盖了处理数组、字典、元组和命名元组。
-
第五章讨论了与 Julia 中集合操作相关的高级主题,包括参数化类型的广播和子类型规则。它还涵盖了将 Julia 与 Python 集成的相关内容。
-
第六章教你如何在 Julia 中处理字符串。此外,它还涵盖了使用符号、处理固定宽度字符串以及使用 PooledArrays.jl 包压缩向量的主题。
-
第七章专注于处理时间序列数据和缺失值。它还涵盖了使用 HTTP 查询获取数据以及解析 JSON 数据。
在第二部分,你将学习如何在 DataFrames.jl 包的帮助下构建数据分析管道。虽然,在一般情况下,你可以仅使用第一部分中学习的数据结构进行数据分析,但通过使用数据表构建你的数据分析工作流程将更加容易,同时也能确保你的代码效率。以下是第二部分你将学习的内容:
-
第八章教你如何从 CSV 文件创建数据表,并在数据表上执行基本操作。它还展示了如何在 Apache Arrow 和 SQLite 数据库中处理数据,处理压缩文件以及进行基本的数据可视化。
-
第九章展示了如何从数据表中选择行和列,你还将学习如何构建和可视化局部估计散点图平滑(LOESS)回归模型。
-
第十章涵盖了创建新数据帧以及用新数据填充现有数据帧的各种方法。它讨论了 Tables.jl 接口,这是一个表概念的实现无关抽象。你还将学习如何将 Julia 与 R 集成以及序列化 Julia 对象。
-
第十一章教你如何将数据帧转换为其他类型的对象。其中一种基本类型是分组数据帧。你还将了解类型稳定的代码和类型盗用等重要通用概念。
-
第十二章专注于数据帧对象的转换和变异——特别是使用拆分-应用-组合策略。此外,本章还涵盖了使用 Graphs.jl 包处理图数据的基础知识。
-
第十三章讨论了 DataFrames.jl 包提供的先进数据帧转换选项,以及数据帧排序、连接和重塑。它还教你如何在数据处理管道中链式执行多个操作。从数据科学的角度来看,本章展示了如何在 Julia 中处理分类数据并评估分类模型。
-
第十四章展示了如何在 Julia 中构建一个提供由分析算法生成数据的 Web 服务。此外,它还展示了如何通过利用 Julia 的多线程功能来实施蒙特卡洛模拟并使其运行更快。
本书以三个附录结束。附录 A 提供了关于 Julia 的安装和配置以及与 Julia 工作相关的常见任务的基本信息,特别是包管理。附录 B 包含了章节中提出的练习的答案。附录 C 对 Julia 包生态系统进行了回顾,这对于你的数据科学和数据工程项目将非常有用。
关于代码
本书包含许多源代码示例,既有编号列表,也有与普通文本混排。在两种情况下,源代码都以固定宽度字体格式化,如这样,以将其与普通文本区分开来。有时代码也会被加粗,以突出显示本章中从先前步骤中更改的代码,例如当新功能添加到现有代码行时。
此外,当在文本中描述代码时,源代码中的注释通常已从列表中删除。代码注释伴随着许多列表,突出显示重要概念。
本书使用的所有代码都可在 GitHub 上找到,网址为github.com/bkamins/JuliaForDataAnalysis。代码示例旨在在终端的交互会话中执行。因此,在书中,在大多数情况下,代码块都显示了带有 julia>提示符的 Julia 输入和命令下面的输出。这种风格与你的终端显示相匹配。以下是一个示例:
julia> 1 + 2 ❶
3 ❷
❶ 1 + 2 是用户执行的 Julia 代码。
❷ 3 是 Julia 在终端中打印的输出。
本书展示的所有材料都可以在 Windows、macOS 或 Linux 上运行。你应该能够在拥有 8GB RAM 的机器上运行所有示例。然而,一些代码列表需要更多的 RAM;在这些情况下,我在书中给出了警告。
如何运行本书中展示的代码
为了确保书中展示的所有代码在你的机器上正确运行,首先遵循附录 A 中描述的配置步骤是至关重要的。
本书是用 Julia 1.7 编写的并进行了测试。
一个特别重要的点是,在运行示例代码之前,你应该始终激活书中 GitHub 仓库提供的项目环境,网址为github.com/bkamins/JuliaForDataAnalysis。
尤其重要的是,在运行示例代码之前,你应该始终激活书中 GitHub 仓库提供的项目环境,网址为github.com/bkamins/JuliaForDataAnalysis。
书中展示的代码不是通过复制粘贴到你的 Julia 会话中执行。始终使用你在本书 GitHub 仓库中可以找到的代码。对于每一章,仓库都有一个单独的文件,包含该章的所有代码。
liveBook 讨论论坛
购买《Julia for Data Analysis》包括对 Manning 在线阅读平台 liveBook 的免费访问。使用 liveBook 的独家讨论功能,你可以对整本书、特定章节或段落添加评论。为自己做笔记、提出和回答技术问题、从作者和其他用户那里获得帮助都非常简单。要访问论坛,请访问livebook.manning.com/book/julia-for-data-analysis/discussion。你还可以在livebook.manning.com/discussion了解更多关于 Manning 论坛和行为准则的信息。
Manning 对我们读者的承诺是提供一个场所,在那里个人读者之间以及读者与作者之间可以进行有意义的对话。这不是对作者参与特定数量承诺的承诺,作者对论坛的贡献仍然是自愿的(且未付费)。我们建议你尝试向作者提出一些挑战性的问题,以免他的兴趣转移!只要本书有售,论坛和以前讨论的存档将从出版社的网站提供访问。
其他在线资源
以下是在阅读本书时你可能觉得有用的精选在线资源列表:
-
DataFrames.jl 文档(
dataframes.juliadata.org/stable/),包含教程链接 -
使用 Julia 进行动手数据科学 liveProject (
www.manning.com/liveprojectseries/data-science-with-julia-ser),这是一项设计为阅读本书后的后续资源,你可以用它来测试你的技能,并学习如何使用 Julia 进行高级机器学习模型。 -
我的每周博客 (
bkamins.github.io/),我在那里写有关 Julia 语言的文章
此外,还有许多关于 Julia 的一般信息的有价值来源。以下是其中一些最受欢迎的选择:
-
Julia 语言网站 (
julialang.org) -
JuliaCon 会议 (
juliacon.org) -
Discourse (
discourse.julialang.org) -
Slack (
julialang.org/slack/) -
Forem (
forem.julialang.org) -
Stack Overflow (
stackoverflow.com/questions/tagged/julia) -
Julia YouTube 频道 (www.youtube.com/user/julialanguage)
-
Talk Julia 播客 (www.talkjulia.com)
-
JuliaBloggers 博客聚合器 (
www.juliabloggers.com)
关于作者

Bogumił Kamiński 是 DataFrames.jl 的主要开发者,这是 Julia 生态系统中进行数据处理的核心包。他在为商业客户提供数据科学项目方面拥有超过 20 年的经验。Bogumił 在本科和研究生层次上教授数据科学也超过 20 年。
关于封面插图
《Julia for Data Analysis》封面上的图像是“Prussienne de Silésie”,或“西里西亚的普鲁士人”,取自 Jacques Grasset de Saint-Sauveur 的收藏,该收藏于 1797 年出版。每一幅插图都是手工精细绘制和着色的。
在那些日子里,人们通过他们的服饰很容易就能识别出他们住在哪里,他们的职业或社会地位是什么。Manning 通过基于几个世纪前丰富多样的地区文化的书封面来庆祝计算机行业的创新和主动性,这些文化通过像这样的收藏品中的图片被重新带回生活。
1 引言
本章涵盖
-
Julia 的关键特性
-
为什么用 Julia 进行数据科学?
-
Julia 中的数据分析模式
数据分析已经成为几乎所有专业活动的核心过程之一。数据的收集变得更容易、更便宜,因此我们能够轻松地访问它。关键方面是数据分析使我们能够以更低的成本和更快的速度做出更好的决策。
数据分析的需求催生了几个新的职业,其中数据科学家通常是最先想到的。数据科学家 是一个擅长收集数据、分析数据并产生可操作见解的人。与所有工匠一样,数据科学家需要能够帮助他们高效可靠地交付产品的工具。
各种软件工具可以帮助数据科学家完成他们的工作。其中一些工具使用图形界面,因此易于使用,但通常也有限制它们的使用方式。数据科学家需要完成的众多任务通常导致他们迅速得出结论,他们需要使用编程语言来实现所需的灵活性和表现力。
开发者已经提出了许多数据科学家常用的编程语言。其中之一是 Julia,它被设计用来解决数据科学家在使用其他工具时面临的挑战。引用 Julia 的创造者的话,它“运行得像 C 语言,但读起来像 Python。”与 Python 类似,Julia 支持高效便捷的开发过程。同时,用 Julia 开发的程序性能与 C 语言相当。
在 1.1 节中,我们将讨论支持这些主张的典型基准测试的结果。值得注意的是,2017 年,一个用 Julia 编写的程序在处理天文图像数据时,使用 130 万个线程实现了 1.54 petaflops(每秒数十亿次的浮点运算)的峰值性能。julia computing.com/case-studies/celeste/。在此之前,只有用 C、C++ 和 Fortran 实现的软件才能达到超过 1 petaflops 的处理速度。
在这本书中,你将学习如何使用 Julia 语言来完成数据科学家需要例行完成的任务:以不同格式读取和写入数据,以及转换、可视化和分析数据。
1.1 什么是 Julia 以及为什么它有用?
Julia 是一种既高级又具有高执行速度的编程语言。创建和运行 Julia 程序都很快。在本节中,我将讨论 Julia 为什么在数据科学家中越来越受欢迎的原因。
不同的编程语言常用于数据分析,例如(按字母顺序排列)C++、Java、MATLAB、Python、R 和 SAS。其中一些语言——例如 R——被设计成在数据科学任务中非常易于表达和使用;然而,这通常是以程序执行时间较慢为代价的。其他语言,如 C++,更接近底层,这使得它们能够快速处理数据;不幸的是,用户通常必须付出编写更冗长代码和较低抽象级别的代价。
图 1.1 比较了 C、Java、Python 和 Julia 在 10 个选定问题中的执行速度和代码大小(编程语言表达性的可能度量之一)。由于这些比较总是很难客观进行,我选择了计算机语言基准游戏(mng.bz/19Ay),它有着悠久的发展历史,维护者尝试使其尽可能客观。
在图 1.1 的两个子图中,C 对每个问题的参考值都是 1;小于 1 的值表明代码比 C 运行得更快(左图)或更小(右图)。在左图中,表示执行时间的 y 轴具有对数尺度。右图中的代码大小是使用每种语言编写的程序的 gzip 存档的大小。
在执行速度(左图)方面,C 是最快的,其次是 Julia(用圆圈表示)。值得注意的是,Python(用菱形表示)在许多任务中比所有其他显示的语言慢几个数量级(我不得不将 y 轴绘制在对数尺度上,以便使左图可读)。
当考虑代码大小(右图)时,Julia 在 10 个任务中有 8 个领先,而对于 C 和 Java,我们看到最大的测量值。除了代码大小外,语言的易用性也很相关。我在 Julia 中准备了图 1.1 的图表,在一个交互会话中,我可以轻松调整它;您可以在与本书配套的 GitHub 仓库中检查源代码(github.com/bkamins/JuliaForDataAnalysis)。这也会在 Python 中很方便,但与 Java 或 C 相比更具挑战性。

图 1.1 比较了 C、Python、Java 和 Julia 在 10 个选定的计算问题中的代码大小和执行速度
在过去,开发者面临着语言表达性和速度之间的权衡。然而,在实践中,他们希望两者兼得。理想的编程语言应该易于学习和使用,就像 Python 一样,同时又能像 C 一样允许高速数据处理。
这通常需要数据科学家在他们的项目中使用两种语言。他们使用易于编码的语言(例如,Python)原型化他们的算法,然后识别性能瓶颈,并将代码的选定部分移植到快速语言(例如,C)。这种转换需要时间,并可能引入错误。维护一个包含大量部分用两种编程语言编写的代码库可能具有挑战性,并引入了集成多种技术的复杂性。最后,当处理具有挑战性和新颖性的问题时,使用两种编程语言编写的代码使得快速实验变得困难,这增加了从产品概念到市场可用性的时间。
Timeline 案例研究
让我以我使用 Julia 的经验为例。Timeline是一个帮助财务顾问进行退休财务规划的网络应用程序。这样的应用程序,为了提供可靠的推荐,需要进行大量的即时计算。最初,Timeline 的创建者开始使用 MATLAB 进行原型设计,然后切换到 Elixir 进行在线部署。我参与了将解决方案迁移到 Julia 的工作。
在代码重写之后,系统的在线查询时间从 40 秒减少到了 0.6 秒。为了评估这种加速的商业价值,想象一下你是一个 Timeline 用户,需要等待 40 秒才能得到你的网页浏览器的响应。现在假设等待时间缩短到了 0.6 秒。除了提高客户满意度外,更快的处理时间也降低了运营此系统所需的技术基础设施的成本和复杂性。
然而,执行速度只是变化的一个方面。另一个方面是,Timeline 报告称,切换到 Julia 节省了数万美元的编程时间和调试时间。软件开发者需要编写的代码更少,而与软件开发者沟通的数据科学家现在使用相同的工具。你可以在juliacomputing.com/case-studies/timeline/了解更多关于这个用例的信息。
在我看来,Timeline 的例子对于将工作结果部署到生产环境的数据科学团队管理者来说尤其相关。即使是单个开发者也会欣赏使用单一语言进行原型设计和编写高性能生产代码带来的生产力提升。然而,当拥有能够使用单一工具进行协作的数据科学家、数据工程师和软件开发者的混合团队时,生产时间和开发成本的真正收益才会显现。
时间线案例研究展示了 Julia 如何在现实生活中的商业应用中取代 MATLAB 和 Elixir 语言的组合。为了补充这个例子,检查数据科学家通常使用的流行开源软件项目所使用的语言(截至 2021 年 10 月 11 日的统计数据)是有教育意义的。表 1.1 显示了用于实现三个 R 和 Python 包的前两种编程语言(按源代码行数的百分比)。
表 1.1 实现所选流行开源包使用的语言
| 包 | 功能 | URL | 语言 |
|---|---|---|---|
| data.table | R 的数据帧包 | github.com/Rdatatable/data.table |
C 36.3%,R 62.4% |
| randomForest | R 的随机森林算法 | github.com/cran/randomForest |
C 50.3%,R 39.9% |
| PyTorch | Python 的机器学习库 | github.com/pytorch/pytorch |
C++ 52.6%,Python 36.6% |
所有这些例子都有一个共同特征:数据科学家希望使用高级语言,如 Python 或 R,但由于代码的部分执行速度太慢,包的编写者必须切换到底层语言,如 C 或 C++。
为了解决这个挑战,一群开发者创建了 Julia 语言。在他们名为“我们为什么创建 Julia”的宣言中,Julia 的开发者将这个问题称为双语言问题(mng.bz/Poag)。
Julia 的美丽之处在于我们不必做出这样的选择。它为数据科学家提供了一个高级、易于使用且快速的编程语言。这一点在 Julia 及其包的源代码结构中得到了体现。表 1.2 列出了与表 1.1 中列出的包功能大致匹配的包。
表 1.2 与表 1.1 中列出的包功能匹配的 Julia 包
| 包 | 功能 | URL | 语言 |
|---|---|---|---|
| DataFrames.jl | 数据帧包 | github.com/JuliaData/DataFrames.jl |
Julia 100% |
| DecisionTree.jl | 随机森林库 | github.com/bensadeghi/DecisionTree.jl |
Julia 100% |
| Flux.jl | 机器学习包 | github.com/FluxML/Flux.jl |
Julia 100% |
所有这些包都是纯 Julia 编写的。但这对于用户来说重要吗?
就像几年前我一样,你可能认为这个特性对包开发者比终端用户数据科学家更相关。Python 和 R 都有成熟的包生态系统,你可以预期大多数计算密集型算法已经在一个你可以使用的库中实现。这确实是事实,但当我们从实现玩具示例转移到复杂的生产解决方案时,我们很快遇到了三个显著的限制:
-
“大多数算法”与“所有算法”不同。虽然在你的大多数代码中你可以依赖包,但一旦你开始进行更高级的项目,你很快就会意识到你需要编写自己的快速代码。很可能会发生的是,你不想为这样的任务切换你使用的编程语言。
-
许多提供数据科学算法实现的库允许用户传递自定义函数,这些函数作为主算法的一部分执行计算。一个例子是将目标函数(也称为损失函数)传递给一个执行神经网络训练的算法。通常,在训练过程中,目标函数会被评估多次。如果你想计算速度快,你需要确保目标函数的评估速度快。
如果你使用 Julia,你可以灵活地定义你想要的自定义函数,并且可以确信整个程序将运行得很快。原因是 Julia 将代码(包括库代码和你的自定义代码)一起编译,从而允许进行优化,这些优化在预编译的二进制文件使用时或当自定义函数是用解释型语言编写时是不可能的。这类优化的例子包括函数内联(
compileroptimizations.com/category/function_inlining.htm)和常量传播(compileroptimizations.com/category/constant_propagation.htm)。我不会详细讨论这些主题,因为你不需要确切了解 Julia 编译器的具体工作方式才能高效地使用它;你可以参考前面的链接获取更多关于编译器设计的详细信息。 -
作为用户,你将想要分析你使用的包的源代码,因为你经常需要详细了解某些东西是如何实现的。如果包是用高级语言实现的,这将更容易做到。更重要的是,在某些情况下,你可能想要使用包的源代码——例如,作为实现其设计者未预见到特性的起点。如果包是用你调用它的语言编写的,这将更容易做到。
为了更详细地解释这里提出的论点,下一节将介绍数据科学家通常认为至关重要的 Julia 的关键特性。
1.2 从数据科学家视角看 Julia 的关键特性
Julia 及其包生态系统具有五个与数据科学家相关的关键特性:
-
代码执行速度
-
专为交互式使用设计
-
可组合性,导致高度可重用且易于维护的代码
-
包管理
-
与其他语言的集成容易
让我们更详细地探讨这些特性的每一个。
1.2.1 Julia 快速的原因在于它是一种编译型语言
我们从执行速度开始,因为这是 Julia 首先承诺的特性。实现这一功能的关键设计元素是 Julia 是一个编译型语言。一般来说,在 Julia 代码执行之前,它会被编译成本地汇编指令,使用 LLVM 技术(llvm.org/)。选择使用 LLVM 确保了 Julia 程序可以轻松地在各种计算环境中移植,并且它们的执行速度得到了高度优化。其他编程语言,如 Rust 和 Swift,也出于相同的原因使用 LLVM。
从性能角度来看,Julia 是编译型语言的事实带来了一个主要的好处。窍门在于编译器可以执行许多优化,这些优化不会改变代码的运行结果,但会提高其性能。让我们看看它是如何工作的。以下示例代码应该很容易理解,即使对于那些没有 Julia 先验经验的人也是如此:
julia> function sum_n(n)
s = 0
for i in 1:n
s += i
end
return s
end
sum_n (generic function with 1 method)
julia> @time sum_n(1_000_000_000)
0.000001 seconds
500000000500000000
注意:你可以在第二章中找到 Julia 语法的介绍,附录 A 将指导你完成 Julia 的安装和配置过程。
在这个例子中,我们定义了一个名为 sum_n 的函数,它接受一个参数 n,并计算从 1 到 n 的数字之和。接下来,我们调用这个函数,要求为 n 等于十亿的求和。函数调用前的@time 注解要求 Julia 打印出我们代码的执行时间(技术上,它是一个宏,我在第三章中会解释)。正如你所看到的,结果产生得非常快。
你可能可以想象,在这个时间框架内执行在 sum_n 函数体内定义的循环的一亿次迭代是不可能的;这肯定需要更多的时间。确实如此。Julia 编译器所做的是意识到我们正在对一系列数字进行求和,因此它应用了一个从 1 到 n 的数字求和的已知公式,即 n(n + 1)/2。这使得 Julia 能够大幅减少计算时间。
这只是 Julia 编译器可以执行的优化示例之一。诚然,像 R 或 Python 这样的语言的实现也试图执行优化以加快代码执行速度。然而,在 Julia 中,在编译期间可以获得更多关于处理值的类型和执行代码结构的信息,因此可以执行更多的优化。“Jeff Bezanson 等人所著的《Julia:数值计算的新方法》”(语言创造者;见mng.bz/JVvP)提供了关于 Julia 设计更详细的解释。
这只是 Julia 编译特性可以加快代码执行速度的一个例子。如果你对分析精心设计的、比较不同编程语言的基准测试源代码感兴趣,我建议你查看我用来创建图 1.1 的计算机语言基准测试游戏(mng.bz/19Ay)。
Julia 的另一个相关方面是它内置了对多线程(使用机器的多个处理器进行计算)和分布式计算(能够在计算中使用多个机器)的支持。此外,通过使用额外的包如 CUDA.jl (github.com/JuliaGPU/CUDA.jl),你可以在 GPU 上运行 Julia 代码(我提到过这个包是 100% 用 Julia 编写的吗?)。这实际上意味着 Julia 允许你充分利用可用的计算资源,从而减少你等待计算结果所需的时间。
1.2.2 Julia 提供了对交互式工作流程的全面支持
你现在可能会问一个很自然的问题:由于 Julia 编译成原生机器码,数据科学家——他们大多数工作都是在探索和交互式方式下进行的——怎么会觉得使用它很方便?通常,当我们使用编译型语言时,编译和执行阶段是明确分开的,这与需要响应式环境的需求不太相符。
但这里出现了 Julia 语言的第二个特性:它是 为交互式使用而设计的。除了运行 Julia 脚本外,你还可以使用以下功能:
-
一个交互式外壳,通常称为读取-评估-打印循环(REPL)。
-
Jupyter Notebook(你可能听说过 Jupyter 的名字是对支持的三种核心编程语言的引用:Julia、Python 和 R)。
-
Pluto.jl 笔记本 (
github.com/fonsp/Pluto.jl),它利用 Julia 的速度,将笔记本的概念提升到了新的水平。当你更改代码中的某个内容时,Pluto.jl 会自动更新整个笔记本中所有受影响的计算结果。
在所有这些场景中,当用户尝试执行 Julia 代码时,代码会被编译。因此,编译和执行阶段被融合在一起,并从用户那里隐藏起来,确保用户体验类似于使用解释型语言。
这种相似性并不仅限于此;像 R 或 Python 一样,Julia 是 动态类型化的。因此,在编写代码时,你不需要(但也可以)指定你使用的变量的类型。Julia 设计的美丽之处在于,由于它是编译型的,这种动态性仍然允许 Julia 程序运行得很快。
在这里需要强调的是,只有用户不需要注释所使用的变量的类型。在运行代码时,Julia 会知道这些类型。这不仅确保了代码执行的速度,还允许编写高度可组合的软件。大多数 Julia 程序都试图遵循众所周知的 UNIX 原则:做好一件事,做好一件事。你将在下一节看到一个例子,并在本书的其余部分学习到更多。
1.2.3 Julia 程序高度可重用且易于组合
当在 Python 中编写函数时,你通常必须考虑用户是否会传递一个标准列表、一个 NumPy ndarray 或一个 pandas Series 给它。这通常需要多次编写类似的代码。然而,在 Julia 中,你通常可以编写一个函数,该函数将接收一个向量,然后这个函数就可以正常工作了。用户传递的具体向量实现对你的代码来说并不重要,因为代码可以是完全通用的。在编译过程中,Julia 会选择执行代码的最有效方式(这是通过第三章中提到的 多重分派 实现的)。
这正是我们在本书中大量使用的 DataFrames.jl 包所采取的方法。DataFrame 的对象用于处理表格数据,并且可以存储任意列。DataFrames.jl 包(github.com/JuliaData/DataFrames.jl)在这里没有任何限制。
例如,DataFrame 可以存储在 Arrow.jl 包中定义的自定义类型(github.com/JuliaData/Arrow.jl)。这些列不遵循标准的 Julia Vector 类型,而是遵循 Apache Arrow 格式(arrow.apache.org/)。你将在第八章中学习如何处理这些数据。在 Julia 中实现此格式的自定义类型设计得非常高效,即使是潜在非常大的 Arrow 数据也能快速读取。
为了参考,让我给你简要介绍一下 Apache Arrow。这种与语言无关的列式内存格式是为了高效的分析操作而组织的。它可以用于读取和写入 Apache Parquet 文件(parquet.apache.org/),并且受到包括 PySpark (spark.apache.org/docs/latest/api/python/) 和 Dask (docs.dask.org/en/stable/) 在内的流行框架的支持。
从 Julia 语言设计原则的角度来看,重要的是要强调 DataFrames.jl 和 Arrow.jl 是完全独立的包。尽管它们不知道对方的存在,但它们可以无缝地一起工作,因为它们依赖于共同的接口(在这种情况下,这个接口是通过我们在第二章和第三章中讨论的 AbstractVector 类型提供的)。同时,当 Julia 执行你的代码时,它会生成高效的本地汇编指令,利用你使用的具体向量类型。因此,如果你的项目中出于某种原因需要使用专有向量类型,DataFrames.jl 不会有问题,而且不仅会工作,而且效率会很高。
让我在这里强调,在 Julia 中,可组合性自然地与函数参数的 Julia 允许的(可选)类型限制相结合(你将在第三章中学习如何编写具有参数类型限制的方法)。当你处理大型项目时,你会欣赏这个特性,因为它允许你轻松地找到代码中的错误或当你阅读代码时理解代码的工作方式。如果你使用 Python,你可能知道自 3.5 版本以来,它支持类型提示,因为它们很有用,尤其是在许多开发者共同参与大型项目时。Python 与 Julia 的不同之处在于,在 Python 中,类型提示只是注释,运行时不会进行类型检查(peps.python.org/pep-0484/)。另一方面,在 Julia 中,如果你在代码中提供了类型限制,编译器将强制执行它们,这样你可以确信只有你期望执行的内容会无误地执行。
1.2.4 Julia 拥有一个内置的先进的包管理器
现在让我们转向从软件工程的角度来看 Julia 的重要特性。首先,Julia 自带了一个先进的包管理器,它允许你轻松管理代码设计的运行环境状态。我在附录 A 中解释了它的细节,但一种实用的思考方式如下。
要完全指定你的 Julia 环境的状态,只需共享两个文件,即 Project.toml 和 Manifest.toml,这两个文件唯一地标识了你的代码使用的包版本,以及你的程序源代码。如果你这样做,Julia 将自动重新创建你的代码正确运行所需的整个运行时环境配置。这样,Julia 确保了你的程序结果的可重复性。此外,Julia 解决了管理用其他语言编写的代码(通常称为依赖地狱)的常见问题,在这种情况下,程序员在正确设置其他软件所需的包时可能会遇到困难。
1.2.5 将现有代码与 Julia 集成很容易
第二个工程方面是与其他语言的集成容易性。Julia 的创造者意识到,在考虑使用这种语言时,你可能会有大量用其他语言编写的现有解决方案。因此,Julia 自带了对调用 C 和 Fortran 代码的原生支持,而与 C++、Java、MATLAB、Python 和 R 代码的集成则由包提供。
这种方法最小化了在具有大量遗留代码库的企业环境中将 Julia 作为首选语言使用的成本。在第五章中,你将看到一个将 Julia 与 Python 集成的例子,在第十章中,与 R 的集成。也存在一些包,使得从其他语言(如 C、Python 或 R)调用 Julia 代码变得容易。
在本节中,我专注于 Julia 语言的特点。然而,就像每一种技术一样,Julia 也有其局限性。接下来,我将描述本书中介绍的包旨在设计的计算任务类型。
1.3 书中介绍工具的使用场景
这本书的重点在于向您展示如何进行表格数据的分析。表格数据是一种由单元格组成的二维结构。每一行都有相同数量的单元格,并提供关于数据的一个观测值的信息。每一列都有相同数量的单元格,存储关于观测值之间相同特征的信息,并且有一个你可以参考的名字。
虽然听起来可能有些限制,但表格数据格式非常灵活且易于处理。诚然,有时你可能想要处理非结构化数据,即使是对于那些项目,你最终也会处理表格数据。因此,学习如何处理它是使用 Julia 进行数据科学的好起点。
这里是来自 DataFrames.jl 包的 DataFrame 类型的样本表的打印输出:
julia> using DataFrames
julia> DataFrame(id=1:3,
name=["Alice", "Bob", "Clyde"],
age=[19, 24, 21], friends=[[2], [1, 3], [2]],
location=[(city="Atlanta", state="GA"),
(city="Boston", state="MA"),
(city="Austin", state="TX")])
3×5 DataFrame
Row │ id name age friends location
│ Int64 String Int64 Array... NamedTup...
─────│─────────────────────────────────────────────────────────────────
1 │ 1 Alice 19 [2] (city = "Atlanta", state = "GA")
2 │ 2 Bob 24 [1, 3] (city = "Boston", state = "MA")
3 │ 3 Clyde 21 [2] (city = "Austin", state = "TX")
这个表格有三行,每行都包含一个学生的信息。表格还有五列:
-
id—表示学生标识符的整数。
-
name—表示学生名字的字符串。
-
age—表示学生年龄的整数。
-
friends—包含给定学生朋友 ID 的可变长度向量。这种类型的数据通常被称为嵌套的,因为列的各个元素是数据集合。
-
location—另一个嵌套列,包含有关学生居住的城市和州的信息(技术上,该列的元素具有 NamedTuple 类型;我将在第一部分中向你展示如何处理这些对象)。
在第一部分,我们将详细讨论这个表格列中存储的数据类型。然而,你可能已经注意到了 DataFrame 列中可以存储的信息类型的巨大灵活性。
在这本书中,我们将主要讨论可以存储在单台计算机的随机访问内存(RAM)中并使用 CPU 处理的数据处理工具。这目前是数据分析的一个常见应用场景。你将要学习的包将确保这种数据处理可以方便且高效地进行。
然而,通常情况下,我们可能想要处理比可用 RAM 量更大的数据,将其分布式处理在多台机器上,或者使用 GPU 进行计算。如果你对这些应用感兴趣,我建议将《Julia 手册》中的“并行计算”部分作为起始参考点(mng.bz/E08q)。此外,附录 C 展示了 Julia 在数据库支持和数据存储格式方面提供的各种选项。
1.4 Julia 的缺点
当你阅读有关 Julia 优势的内容时,你可能会认为它在尝试“既要又要”时,结合了代码编译和交互式用例所需的动态性。肯定有什么问题存在。
的确,有一个问题。问题是容易识别的:编译需要时间。第一次运行一个函数时,它必须在执行之前进行编译。对于小型函数,这个成本是可以忽略不计的,但对于复杂的函数,执行可能需要几秒钟。这个问题在 Julia 社区中被称为“首次绘图时间问题”。我将在下一个列表中向你展示在一个新的 Julia 会话中生成简单图表的计时。
列表 1.1 测量首次绘制图表的时间
julia> @time using Plots ❶
4.719333 seconds (9.27 M allocations: 630.887 MiB, 6.32% gc time,
20.23% compilation time)
julia> @time plot(1:10) ❷
2.542534 seconds (3.75 M allocations: 208.403 MiB, 1.86% gc time,
99.63% compilation time)
julia> @time plot(1:10) ❸
0.000567 seconds (1.42 k allocations: 78.898 KiB)
❶ 加载 Plots.jl 包所需时间
❷ 生成第一个图表所需时间
❸ 再次生成相同图表所需时间
对 @time 宏的调用(如前所述,你将在第三章学习宏)要求 Julia 为其后表达式的执行时间生成统计数据。在这种情况下,加载 Plots.jl 包需要近 5 秒,生成第一个图表大约需要 2.5 秒(注意,这超过 99% 的时间是编译时间)。然而,再次生成图表却很快,因为图表函数已经编译完成。
这个特定例子中编译时间相对较长的原因是绘图需要非常复杂的底层代码(想象一下当你对生成的图形进行样式设置时所有可能的选择)。在 Julia 的早期阶段,这个问题相当严重,但 Julia 的核心开发者已经投入了大量努力来最小化这个问题。目前,这已经不再是问题,并且随着 Julia 的每次发布,这个问题都会得到改善。尽管如此,这个问题在某种程度上总是会存在,因为这是语言设计的一个固有特性。
一个自然的问题是,在哪些场景下我们应该期待编译成本会很重要。当满足以下两个条件时,成本是相关的:第一个是处理少量数据,第二个是 Julia 进程在终止前只执行少量操作。如果你有大量数据(例如,处理需要一小时),支付几秒钟的编译成本并不明显。同样,如果你开始一个长时间的交互会话或启动一个 Julia 服务器,该服务器响应许多请求而不会被终止(如 Timeline 案例研究所示),编译的平均成本可以忽略不计,因为你只会在第一次运行函数时支付它。然而,如果你想要快速启动 Julia,绘制一个简单数据的图表,然后退出 Julia,编译时间就会很明显,很可能是令人烦恼的(在列表 1.1 中,我们可以在我的笔记本电脑上看到执行此类任务需要大约 7 秒)。
用户也经常询问是否可以从 Julia 代码中创建一个可执行文件,该文件可以在没有安装 Julia 的机器上运行,而不会牺牲执行速度。这可以通过使用 PackageCompiler.jl 包(github.com/JuliaLang/PackageCompiler.jl)来实现。然而,与例如用 C 语言编写的应用程序相比,在运行时,此类应用程序将具有更大的可执行文件大小和更大的 RAM 内存占用。在某些 RAM 空间有限的上下文中(例如,嵌入式系统),用户可能会发现这很成问题。你可以期待在 Julia 的未来版本中这种情况会有所改善。
最后,你可能已经听说 Julia 是编程语言领域的一个相对较新的参与者。这自然会引发对其成熟度和稳定性的疑问。在这本书中,我们将关注那些已经达到生产级稳定性的包。正如你将学到的,这些包提供了数据科学家通常需要的所有标准功能。
然而,Python 或 R 的包生态系统范围更广。因此,在某些特定情况下,你可能在 Julia 生态系统中找不到合适的包,或者可能认为某个包不够成熟,不足以用于生产目的。这时,你可能需要决定是否放弃使用 Julia,或者使用 RCall.jl 或 PyCall.jl 等包,这些包允许你轻松地在 Julia 程序中使用 R 或 Python 库(这正是我通常的做法)。在本书中,你将看到此类集成的示例,以便你可以验证它确实很方便。
1.5 你将学习哪些数据分析技能?
本书通过 Julia 语言为你提供了一种动手学习数据分析的方法。本书的目标读者包括数据科学家、数据工程师、计算机科学家和希望学习一种能够以高效便捷的方式帮助你从数据中获得宝贵见解的新技术的商业分析师。
读者最好有一些 Julia 编程经验,以便从阅读中获得最大收益。然而,我认识到 Julia 是一种新技术,知道它的人为数不多。因此,第一部分包括几个介绍 Julia 语言的章节。在第二部分,你将在 Julia 中学习以下技能:
-
以各种常见格式读取和写入数据
-
在处理表格数据时执行常见任务,包括子集、分组、汇总、转换、排序、连接和重塑
-
通过使用各种类型的图表来可视化你的数据
-
使用收集到的数据进行数据分析并构建预测模型
-
创建复杂的数据处理管道,结合前面列表中描述的所有组件
1.6 Julia 如何用于数据分析?
大多数数据分析项目遵循类似的流程。在本节中,我概述了这个过程步骤的高级图(图 1.2)。对于每个步骤,我都列出了数据科学家为完成它而执行的一些典型任务。Julia 提供了一套完整的功能,允许你在实际项目中执行这些任务,在接下来的章节中,你将学习如何完成所有这些任务。

图 1.2 典型的数据处理流程。使用 Julia 语言,数据科学家可以执行数据分析的所有步骤。
Julia 包生态系统中的工具涵盖了典型数据分析流程中的所有步骤:
-
源数据摄取—Julia 可以原生地从各种来源读取数据,例如,包括逗号分隔值(CSV)、Arrow、Microsoft Excel 或 JavaScript 对象表示法(JSON)。值得注意的是,与 R 或 Python 相比,Julia 是编写针对来自非标准来源的数据的高效自定义解析器的优秀工具,这在物联网(IoT)应用中是一个常见的场景。
-
数据准备—在这个步骤中,典型的数据操作包括连接、重塑、排序、子集、转换和修复质量问题。在这本书中,我们将主要使用 DataFrames.jl 包来完成这些任务;这个包被设计成易于使用且高效,尤其是在执行需要编写自定义函数的非标准数据转换时。正如我在本章中已经讨论过的,进行适当的性能基准测试是具有挑战性的,但如果你是 Python 中的 pandas 用户,你可以期待在切换到 DataFrames.jl 后,你的复杂分割-应用-组合操作或大型连接(这两类操作通常是数据准备中最耗时的步骤)将通常减少一个数量级的时间。
-
数据分析—数据准备完成后,数据科学家希望从中获得洞察;数据可以通过多种方式进行分析,包括聚合和总结、可视化、执行统计分析或构建机器学习模型。与数据准备步骤类似,如果你创建复杂解决方案,使用 Julia 将带来最大的好处。根据我的经验,如果你需要在单个模型中结合机器学习、优化和模拟组件,与 R 或 Python 相比,Julia 的使用特别方便且高效。在第二部分,我们将创建一个示例项目,展示如何将模拟集成到数据分析流程中。
-
共享结果—任何分析的最后一步是将其结果提供给外部受众。这可以简单到将数据保存到持久存储,也可以包括通过交互式仪表板或网络服务(在第十四章中介绍)提供服务,或者将创建的机器学习模型部署到生产环境。在这里,Julia 的关键优势是,如果您将模型部署到生产环境,您不需要将其移植到另一种语言以实现高执行性能;我在第 1.1 节中展示了 Timeline 案例研究作为例子。在第二部分中,我将向您展示如何在 Julia 中创建一个提供数据分析结果给用户的网络服务。
需要强调的是,前面的步骤通常以两种模式进行:
-
交互式—数据科学家以探索和迭代的方式与数据工作,目的是理解它,并从中得出有价值的商业结论。这种模式通常用于在开发环境中工作。
-
全自动—所有分析都在没有任何数据科学家干预的情况下进行。Julia 程序自动执行数据处理管道的所有步骤,并将结果提供给外部进程。这种模式通常用于将代码部署到生产环境时使用。
Julia 及其数据科学相关生态系统被设计成可以方便地在交互式和全自动模式下使用。本书展示了为这两种场景准备的代码示例。
数据分析方法
本节提供了一个数据分析过程的简化视图。如果您想了解更多关于该领域开发的标准,以下是一些提供更深入信息的参考文献:
-
团队数据科学流程(TDSP),
mng.bz/wy0W -
数据挖掘跨行业标准流程(CRISP-DM),
www.statoo.com/CRISP-DM.pdf -
数据库中的知识发现(KDD),
link.springer.com/chapter/10.1007/0-387-25465-X_1 -
样本、探索、修改、建模和评估(SEMMA),
mng.bz/7Zjg
摘要
-
Julia 是一种现代编程语言,旨在满足数据科学家的需求:它既快速又易于使用,无论是交互式使用还是在生产环境中都易于使用。
-
Julia 程序高度可组合,这意味着语言提供的各种包和功能可以轻松地一起使用,同时确保高执行速度。
-
Julia 的设计对工程师友好:它内置了先进的包管理功能,并提供了一种简单的方法来与其他编程语言集成。此外,当你用 Julia 定义函数时,你可以限制它们接受的参数类型。这在处理大型项目时特别有用,因为它允许你快速捕捉代码中的错误,并使理解代码的工作方式变得简单。
-
在这本书中,你将学习如何通过使用成熟且适用于严肃生产使用的 Julia 包来处理表格数据。
-
Julia 生态系统中的包允许你轻松地以各种格式读取和写入数据,处理数据,可视化数据,并创建统计和机器学习模型。
第一部分 必要的 Julia 技能
在本书的第一部分,你将学习在数据科学项目中非常有用的关键 Julia 技能。我已经将内容组织得逐渐变得更加高级。我们从 Julia 的基本语法开始,最后结束于高级主题,如解析 JSON 数据和处理缺失值。
本部分由六个章节组成,组织如下:
-
第二章讨论了 Julia 语法的基础、常见的语言构造以及变量作用域规则最重要的方面。
-
第三章介绍了 Julia 的类型系统和定义方法。它还介绍了处理包和模块,最后讨论了使用宏。
-
第四章涵盖了处理数组、字典、元组和命名元组。
-
第五章讨论了与 Julia 中集合操作相关的高级主题:参数化类型的广播和子类型规则。它还涵盖了使用 t-SNE 维度缩减算法的示例将 Julia 与 Python 集成。
-
第六章教你 Julia 中处理字符串的各个方面。此外,它还涵盖了使用符号、固定宽度字符串以及使用 PooledArrays.jl 包压缩向量的主题。
-
第七章专注于处理时间序列数据和缺失值。它还涵盖了使用 HTTP 查询获取数据以及解析 JSON 数据。
2 Julia 入门
本章涵盖了
-
理解值和变量
-
定义循环、条件表达式和函数
-
Julia 中的变量作用域规则
如果你刚开始接触 Julia 语言,在本章中,你将学习其基本语法和最重要的概念。我们将关注与 Python 和 R 不同的方面。即使你已经知道 Julia,我也建议你快速浏览本章,以确保你对基本概念有完整的理解。
如果你不确定如何安装、设置和使用你的工作环境,如何获取帮助,或者如何安装和管理包,请参阅附录 A。
注意,第一部分中的章节并不是为了成为 Julia 的完整课程。它们只包含你开始使用 Julia 进行数据科学所需的必要信息。我建议你参考 Julia 项目“Books”页面上的书籍(julialang.org/learning/books/)或 Julia 手册(docs.julialang.org/en/v1/)以获得对 Julia 编程的全面介绍。
在本章中,我们的目标是编写一个函数来计算向量的 winsorized 均值。非正式地说,winsorized 均值是将最小值和最大值替换为最接近它们的较不极端的观测值。这样做是为了限制异常值对结果的影响(mng.bz/m2yM)。让我先解释一下你如何计算这个均值。
假设你有一个存储为向量的数字序列,并想计算其均值。然而,你知道你的数据可能包含极端值(异常值),这些值可能会显著影响结果。在这种情况下,你可以使用 winsorized 均值,这是标准均值的修改版。想法是将最极端的观测值替换为较不极端的值。让我们从一个我们将要实现的定义开始。
向量 x 的k 次 winsorized 均值是替换了其 k 个最小元素为(k + 1)次最小元素,以及类似地,每个最大的 k 个元素被(k + 1)次最大的元素替换后的均值(mng.bz/5mWD)。
如果我们假设向量 x 按升序排序且长度为 n,就像在 Xycoon Statistics-Econometrics-Forecasting 网站上所做的那样(www.xycoon.com/winsorized_mean.htm),那么在计算 k 次 winsorized 均值时,我们将 x[1]、x[2]、...、x[k]这些元素替换为 x[k + 1]元素,并将 x[n]、x[n - 1]、...、x[n - k + 1]这些元素替换为 x[n - k]元素。
这里有一个例子。假设我们想要计算向量 [1, 2, 3, 4, 5, 6, 7, 8] 的两次 winsorized 均值,我们将 1 和 2 替换为 3;同样,7 和 8 被替换为 6。这个操作给我们一个向量 [3, 3, 3, 4, 5, 6, 6, 6],其平均值等于 4.5。现在你知道我们需要实现什么了。问题是如何在 Julia 中实现它。
要开发一个计算 winsorized 均值的函数,我们需要介绍 Julia 语言的各种重要部分,从值和变量开始,然后继续到控制流和函数。
2.1 表示值
要创建一个计算 winsorized 均值的函数,我们首先需要了解 Julia 如何表示数字和向量。更普遍地说,了解 Julia 如何处理值是非常重要的。
一个 值 是计算机内存中存储的实体的表示,可以被 Julia 程序操作。在这本书中,我也使用术语 对象 来指代值,尤其是在指代具有复杂内部结构的值时(例如,在第二部分中讨论的数据帧)。然而,Julia 不是一个面向对象的编程语言,对象没有附加到它们的方法。相反,Julia 支持多态,我们将在本章后面简要讨论。
在讨论如何操作值之前,让我们看看如何在下一列表中创建它们。每个值都是通过评估 Julia 表达式得到的结果。以下是一些通过评估 字面量(在源代码中表示值)创建的基本示例值。
列表 2.1 通过评估字面量创建值
julia> 1
1
julia> true
true
julia> "Hello world!"
"Hello world!"
julia> 0.1
0.1
julia> [1, 2, 3]
3-element Vector{Int64}:
1
2
3
这些值依次是一个整数 1、布尔值 true、字符串 "Hello world!"、浮点数 0.1 和一个包含三个元素的向量 [1, 2, 3]。
在 Julia 中,每个值的一个重要属性是其类型,您可以通过使用 typeof 函数来检查。在 Julia 中,当您定义一个函数时,您可以可选地声明函数接受的参数类型。例如,在我们的 k 次 winsorized 均值函数中,我们希望确保 k 是一个整数,x 是一个向量。让我们尝试在下一列表中使用 typeof 函数检查 2.1 列表中的值。
列表 2.2 检查值的类型
julia> typeof(1)
Int64
julia> typeof(true)
Bool
julia> typeof("Hello world!")
String
julia> typeof(0.1)
Float64
julia> typeof([1, 2, 3])
Vector{Int64} (alias for Array{Int64, 1})
你可能在这里注意到两件事。首先,对于整数和浮点值,你会在类型名称中看到一个数字 64,即 Int64 和 Float64。这个值很重要。它向用户表明这两个值都占用 64 位内存。一般来说,如果需要,您在这里有灵活性。例如,您可以使用只占用 8 位内存的 Int8 值,但这会牺牲能够表示的值的范围:从 -128 到 127。您可以通过编写 Int8(1) 来创建一个 Int8 值。
在 Julia 中,你可以通过使用 bitstring 函数来检查(如果需要的话),数字的确切内存布局,该函数生成一个包含通过值生成的位序列的字符串。我在下面的代码中展示了这一点,以让你相信,确实在我的机器上,1 和 1.0 占用 64 位,而 Int8(1)占用 8 位。注意,尽管这三个值代表数字 1,但它们在计算机内存中的存储都不同,因为它们的类型不同(如果你想了解更多关于像 1.0 这样的浮点数在计算机内存中是如何存储的,请查看mng.bz/aPDo):
julia> bitstring(1)
"0000000000000000000000000000000000000000000000000000000000000001"
julia> bitstring(1.0)
"0011111111110000000000000000000000000000000000000000000000000000"
julia> bitstring(Int8(1))
"00000001"
在这本书中,我们通常会使用默认的 64 位数字。了解在 64 位机器(你最可能使用的计算机类型)上,你可以通过只输入 Int 来更简洁地引用 Int64 类型是有用的:
julia> Int
Int64
第二个需要注意的事情是[1, 2, 3]向量的类型,它是 Vector{Int64}(Array{Int64, 1}的别名)。这似乎相当冗长。让我们来分析一下。
从 Array{Int64, 1}开始。我们看到我们的向量是 Array 类型。在花括号中,我们得到这个类型的参数:{Int64, 1}。AbstractArray 的子类型通常需要两个参数,而 Array 正好需要两个参数。第一个参数是数组可以存储的元素类型(在我们的例子中,是 Int64)。第二个参数是数组的维度,在这个例子中是 1。
因为在数学中,一维数组通常被称为向量,Julia 允许你只写 Vector{Int64},这意味着与 Array{Int64, 1}相同。由于类型名是 Vector,这意味着它是一维数组,我们可以省略传递维度参数。然而,我们仍然需要传递向量可以存储的元素类型,因此它有一个参数,在这种情况下,是{Int64}。图 2.1 说明了这些概念。

图 2.1 参数类型名称的阅读规则。这两个定义是等价的,因为向量是一维数组;Array{Int64, 1}的第二个参数是数组维度(在这种情况下,1),因此这是一个向量。类型参数被括在花括号中。
除了使用 typeof 函数获取值的类型外,你还可以通过使用 isa 运算符方便地测试一个值是否为特定类型。让我们检查这个[1, 2, 3]向量:
julia> [1, 2, 3] isa Vector{Int}
true
julia> [1, 2, 3] isa Array{Int64, 1}
true
注意,在这个例子中,在 Vector{Int}中,Vector 和 Int 都是别名,而 Array{Int64, 1}是相同的类型。
当你编写自己的代码时,你很可能不会经常使用 typeof 函数和 isa 操作符,因为 Julia 在运行代码时会自动使用类型信息。然而,了解如何手动检查值的类型对于理解 Julia 的工作方式非常重要。例如,了解变量的类型在调试代码时很重要。在第三章,你将学习在 Julia 中定义函数时如何使用变量类型的有关信息。
2.2 定义变量
现在你已经知道了什么是值,你就可以学习关于变量的知识了。在我们的 winsorized mean 函数中,我们需要变量来引用用户传递给函数的值。
变量 是绑定到值的名称。将值绑定到变量名的最简单方法就是使用赋值运算符 =(等号):
julia> x = 1
1
julia> y = [1, 2, 3]
3-element Vector{Int64}:
1
2
3
在这个例子中,我们将整型 1 绑定到变量名 x,并将向量 [1, 2, 3] 绑定到变量名 y。
值绑定与复制
需要强调的是,在 Julia 中,赋值运算符 (=) 仅执行将值绑定到变量的操作。绑定过程不涉及复制值。Python 也遵循这种方法。然而,在 R 中情况并非如此。
这种区别在处理数据集合,例如向量时最为重要,尤其是当你对它们进行修改(例如,添加或更改存储的元素)时。在 Julia 中,如果一个向量绑定到两个不同的变量,并且你对其进行了修改,那么这个变化将在两个变量中都可见。例如,在 Julia 中,如果你写下 x = [1, 2] 然后 y = x,那么 x 和 y 变量绑定到相同的值。如果你接下来写 x[1] = 10,那么 x 和 y 变量的值都将变为 [10, 2]。如果你想将变量 y 绑定到变量 x 绑定的值的副本,请写 y = copy(x)。在这种情况下,修改绑定到 x 的值将不会影响 y。
理解何时发生值绑定与复制特别重要,尤其是在处理数据框的列时。在我的经验中,作为 DataFrames.jl 包的维护者,这个问题是用户代码中主要错误来源之一。在第二部分,你将学习在处理 DataFrames.jl 时,如何决定你执行的操作是否应该复制数据。
需要强调的是,Julia 是一种动态类型语言,因此在编译时它不需要知道绑定到变量的类型。这一事实的实践后果是,你可以在代码中将不同类型的值绑定到相同的变量名。以下是一个例子:
julia> x = 1
1
julia> x
1
julia> typeof(x)
Int64
julia> x = 0.1
0.1
julia> x
0.1
julia> typeof(x)
Float64
在这个例子中,我们首先将一个整型 1(类型为 Int64)绑定到变量 x。接下来,我们将 0.1(类型为 Float64)赋值给同一个变量名。这种行为是 R 或 Python 的用户自然期望的,因为它们也属于动态类型编程语言的类别。
避免将不同类型的值绑定到相同的变量名
为了方便起见,Julia 允许你将不同类型的值绑定到相同的变量名。然而,出于性能考虑,这并不推荐。
正如我们在第一章中讨论的,Julia 是一种编译型语言。在编译过程中,Julia 会尝试自动找到所有可能的值类型,这些值可以被绑定到给定的变量名。如果 Julia 编译器可以证明这是一个单一类型(或者在某些情况下,是一系列几个类型),那么 Julia 就能够生成更高效的代码。
在 Julia 手册中,避免改变绑定到变量上的值类型的代码被称为类型稳定的。编写类型稳定的代码是 Julia 中最重要性能建议之一(mng.bz/69N6)。我们将在第二部分中回到编写类型稳定代码的话题,在处理 DataFrames.jl 的上下文中。
Julia 在变量命名方面提供了很多灵活性。你可以在变量名中使用 Unicode 字符,并且它们是大小写敏感的。以下有三个例子:
julia> Kamiński = 1
1
julia> x1 = 0.5
0.5
julia> ε = 0.0001
0.0001
第一个例子在变量名中使用了ń(波兰字母表中的一个字母)。第二个例子在变量 x[1]的名称中有一个下标 1。最后一个例子使用了希腊字母ε。这种灵活性在你有源材料(例如,文档或研究论文)并且想在代码中使用与文本中相同的符号以使代码更容易理解时最有用。
你可能会问我们如何输入像[1]或ε这样的字符。这很容易检查。在 Julia 的 REPL 中,通过按键盘上的问号键(?)切换到帮助模式(附录 A 解释了如何在 Julia 中使用帮助),然后粘贴你想要调查的字符。以下是你会得到的截断输出:
help?> 1
"1" can be typed by \_1<tab>
help?> ε
"ε" can be typed by \varepsilon<tab>
如你所见,输入这些字符很方便,尤其是如果你是 LaTeX 用户。这种输入方法在所有标准环境中都得到了支持,在这些环境中你可以编写 Julia 代码——例如,Julia REPL、Visual Studio Code 和 Jupyter Notebook。在 Julia 手册中,你可以找到可以通过 tab 补全输入的 Unicode 字符的完整列表(mng.bz/o5Gv)。
2.3 使用最重要的控制流结构
如本章引言中所述,要编写一个计算 winsorized 平均值的函数,我们需要遍历存储在向量中的值并条件性地更改它们。在本节中,你将学习如何执行这些操作。
在本书中,我们将经常使用的三种控制流结构如下:
-
条件评估
-
循环
-
复合表达式
要获取完整列表,请参阅 Julia 手册中的“控制流”部分(mng.bz/ne24)。我现在将解释你如何使用它们中的每一个。
2.3.1 依赖于布尔条件的计算
当我们想要根据特定条件的值采取不同的行动时,会使用条件评估。在本节中,我将向你展示如何在 Julia 中使用条件表达式,以及当你处理布尔条件时应了解的常见模式。
条件表达式
在 Julia 中,条件表达式使用 if-elseif-else-end 语法编写。图 2.2 展示了条件表达式的示例。

图 2.2 列表 2.3 中代码的工作原理说明
下面的列表展示了如何在 Julia 中实现图 2.2 中展示的条件表达式。请注意,在 Julia 中,我们通过使用==运算符来测试两个值是否相等。
列表 2.3 定义条件表达式
julia> x = -7
-7
julia> if x > 0
println("positive")
elseif x < 0
println("negative")
elseif x == 0
println("zero")
else
println("unexpected condition")
end
negative
因为 x 是负数,所以 x > 0 产生 false,而 x < 0 产生 true,所以打印出负数。
在这种语法中,可以省略 elseif 和 else 部分。重要的是要强调,传递给 if 的表达式必须有一个逻辑值。表达式的值类型必须是 Bool;否则,会抛出错误:
julia> x = -7
-7
julia> if x
println("condition was true")
end
ERROR: TypeError: non-boolean (Int64) used in boolean context
Julia 中的代码缩进
在列表 2.3 中,我缩进了四格代码。这是 Julia 中的标准做法,也用于我们将在本章后面讨论的其他情况(循环、函数等)。
与 Python 不同,在 Julia 中,使用缩进是可选的,它旨在提高代码的可读性。一般来说,Julia 在遇到 end 关键字或其他特定于给定语句的关键字(例如,在条件表达式中,这些额外的关键字是 else 和 elseif)时,会识别代码块的结束。
比较浮点数的规则
在列表 2.3 中,当我们检查 x 是正数、负数还是零时,你可能惊讶地发现我包括了 else 部分打印出意外条件。如果 x 是一个数字,它似乎应该满足这些条件之一。
不幸的是,事情比这更复杂。电气和电子工程师协会(IEEE)754 标准的浮点算术定义了一个特殊的 NaN(不是一个数字)值,当使用<、<=、>、>=和==与其他值比较时,总是产生 false,正如你所看到的:
julia> NaN > 0
false
julia> NaN >= 0
false
julia> NaN < 0
false
julia> NaN <= 0
false
julia> NaN == 0
false
根据 IEEE 754 标准,当使用不等于运算符(!=)时,将 NaN 与值比较才会产生 true:
julia> NaN != 0
true
julia> NaN != NaN
true
这表明,在编程语言的环境中应用数学中的常识时必须小心——在理论和在计算机上实现时,并非所有事物都以相同的方式工作。此外,通常不同的编程语言可能会为处理数字实现不同的规则。当处理浮点数时,Julia 遵循 IEEE 754 标准。
浮点值表示数字不精确的后果
由于浮点数只是近似表示实数,因此出现了另一个类似的问题。例如,我们有:
julia> 0.1 + 0.2 == 0.3
false
这很令人惊讶。原因是,由字面量 0.1、0.2 和 0.3 的评估创建的 Float64 值没有一个能精确地表示所写的实数。Julia 所做的是存储最接近请求数字的 Float64 值。因此,我们有一个小但通常是非零的错误。通过写下这个
julia> 0.1 + 0.2
0.30000000000000004
我们可以看到 0.1 和 0.2 的和略大于 0.3。在这种情况下,数据科学家应该怎么办?在 Julia 中,您可以使用 isapprox 函数执行近似比较:
julia> isapprox(0.1 + 0.2, 0.3)
true
您可以通过传递适当的参数来控制 isapprox 如何处理 近似等于 语句;有关详细信息,请参阅 Julia 手册 (mng.bz/gR4x)。您还可以方便地使用默认容差级别的 isapprox 函数,该容差级别对于 Float64 值默认为大约 1.5e-8 的相对容差,通过中缀运算符:
julia> 0.1 + 0.2 ≈ 0.3 ❶
true
❶ 不要将 ≈ 字符与 = 字符混淆。
您可以在 Julia REPL 中通过输入 \approx 并按 Tab 键来获取约等于(≈)字符。
结合多个逻辑条件
您现在应该熟悉编写单个条件。然而,我们经常想要同时测试多个条件。例如,我们可能想要检查一个数字是否既是正数又小于 10。在 Julia 中,您可以通过使用 &&(和)和 ||(或)运算符来组合条件。以下有两个示例:
julia> x = -7
-7
julia> x > 0 && x < 10
false
julia> x < 0 || log(x) > 10
true
为了方便,当使用 && 运算符将针对相同值的比较连接起来时,可以更简洁地书写。因此,您不必写成 x > 0 && x < 10,而可以写成 0 < x < 10,就像在数学文本中书写条件一样。
Julia 中条件的短路评估
Julia 中 && 和 || 运算符的另一个重要特性是它们执行 短路 评估:它们只评估足够多的条件(从最左边开始),以确定整个表达式的逻辑值。您已经在评估表达式 x < 0 || log(x) > 10 时看到了这个特性的工作。原因是,如果 x 有负实数值,log(x) 会抛出错误,正如您在这里看到的:
julia> x = -7
-7
julia> log(x)
ERROR: DomainError with -7.0:
log will only return a complex result if called with a complex argument.
Try log(Complex(x)).
我们在评估 x < 0 || log(x) > 10 时没有看到这个错误的原因是,由于 x 等于 -7,第一个条件 x < 0 是正确的,所以 Julia 从不检查第二个条件。因此,如果您写下
x > 0 && println(x)
Julia 以相同的方式解释它,即
if x > 0
println(x)
end
类似地,
x > 0 || println(x)
等于
if !(x > 0)
println(x)
end
因此,&& 和 || 运算符可以方便地编写执行条件评估的单行代码:
julia> x = -7
-7
julia> x < 0 && println(x²)
49
julia> iseven(x) || println("x is odd")
x is odd
这种模式在 Julia 中用于在简单条件被使用时提高代码的可读性。
让我强调,在这些情况下,表达式的第二部分不需要产生布尔值。这是因为我们例子中 && 和 || 的短路行为等同于编写以下 if 表达式:
julia> x = -7
-7
julia> if x < 0
println(x²)
end
49
julia> if !iseven(x)
println("x is odd")
end
x is odd
然而,请记住,正如我解释的那样,在正常 if 条件中使用不产生布尔值的表达式是不允许的,并且会抛出错误:
julia> x = -7
-7
julia> if x < 0 && x²
println("inside if")
end
ERROR: TypeError: non-boolean (Int64) used in boolean context
三元运算符
在我们结束对检查条件的讨论之前,让我们介绍从 C 编程语言借来的 三元运算符。编写
x > 0 ? sqrt(x) : sqrt(-x)
等价于编写
if x > 0
sqrt(x)
else
sqrt(-x)
end
如你所见,在 ? 符号之前,我们传递的是条件表达式。然后,在 ? 之后,我们传递两个由 : 分隔的表达式,其中只有一个会被评估,这取决于传递的条件是 true 还是 false。
三元运算符用于简短的单行条件。这里有一个更多示例:
julia> x = -7
-7
julia> x > 0 ? println("x is positive") : println("x is not positive")
x is not positive
条件表达式返回一个值
if-elseif-else-end 表达式和三元运算符返回一个值,这是所选分支中最后一个执行的表达式的返回值。如果你想要将这个返回值绑定到一个变量上,这通常很有用。
例如,假设我们想要计算给定数字 x 的绝对值的平方根,并将结果存储在变量 y 中。你可以将这个操作写成 y = sqrt(abs(x)),但让我通过使用条件表达式来展示如何做:
julia> x = -4.0
-4.0
julia> y = if x > 0
sqrt(x)
else
sqrt(-x)
end
2.0
julia> y
2.0
同样的规则适用于三元运算符:
julia> x = 9.0
9.0
julia> y = x > 0 ? sqrt(x) : sqrt(-x)
3.0
julia> y
3.0
2.3.2 循环
在 Julia 中,你可以使用两种类型的循环:for-end 和 while-end。for 循环在实践上可能是更常见的一种。它遍历集合的值。下一个列表显示了一个工作示例。
列表 2.4 定义 for 循环
julia> for i in [1, 2, 3]
println(i, " is ", isodd(i) ? "odd" : "even")
end
1 is odd
2 is even
3 is odd
在这里我们有一个包含三个值的向量 [1, 2, 3]。循环中的每个迭代变量 i 都从这个向量中取连续的值,并执行循环体。isodd(i) ? "odd" : "even" 表达式是一个三元运算符(在第 2.3.1 节中介绍)。
另一方面,while 循环只要满足某个条件就会产生值,如下面的列表所示。
列表 2.5 定义 while 循环
julia> i = 1
1
julia> while i < 4
println(i, " is ", isodd(i) ? "odd" : "even")
global i += 1
end
1 is odd
2 is even
3 is odd
在这里我们有一个变量 i。如果 while 关键字后面的条件为真,则执行循环体。在这种情况下,我们测试 i 是否小于 4。请注意,在循环体中,我们将 i 增加 1,因此最终循环会终止。
在这个例子中,你可以看到全局关键字,我将在我们讨论变量作用域规则的第 2.5 节中解释它。现在,只需理解这个关键字通知 Julia 应该使用我们在 while 循环外部定义的 i 变量就足够了。
我还没有解释的另一种风格是 i += 1。这个语句的意思等同于写作 i = i + 1,但打字更短。在这种情况下,它将变量 i 增加 1。你也可以使用其他运算符的缩写,例如 -=、*= 或 /=。
在 for 和 while 循环中,你可以使用两个特殊的关键字:
-
continue 立即停止一个迭代并移动到下一个迭代。
-
立即中断循环。
通过示例可以最容易地理解这些关键字是如何工作的:
julia> i = 0
0
julia> while true
global i += 1
i > 6 && break
isodd(i) && continue
println(i, " is even")
end
2 is even
4 is even
6 is even
注意,我们写 while true 来设置循环。由于这个条件始终为真,除非我们有其他中断循环的方法,否则它将无限次地运行。这正是 break 关键字所实现的。为了理解这一点,让我们逐行回顾循环体。
在这个循环中,在每次迭代中,变量 i 增加 1。接下来,我们检查是否达到了大于 6 的值,如果是,则终止循环。如果 i 小于或等于 6,我们检查它是否为奇数。如果是这种情况,我们跳过循环体的其余部分;否则,(如果 i 是偶数),我们执行 println(i, " 是偶数")。
2.3.3 复合表达式
在处理数据时,执行多个操作但将它们捆绑在一起,以便从外部看它们像是一个返回其内部最后一个表达式值的单一表达式,这通常很有用。在 Julia 中,你有两种方法可以将多个表达式打包成一个。
第一种方法是使用 begin-end 块。第二种方法更轻量级,允许通过使用分号 (😉 来链表达式。通常,我们需要将用分号分隔的表达式链用括号括起来,以界定复合表达式的范围。下面的列表显示了几个示例。
列表 2.6 使用 begin-end 块或分号 (😉 定义复合表达式
julia> x = -7
-7
julia> x < 0 && begin
println(x) ❶
x += 1
println(x) ❷
2 * x ❸
end
-7
-6
-12
julia> x > 0 ? (println(x); x) : (x += 1; println(x); x) ❹
-5
-5
❶ 打印 -7
❷ 打印 -6
❸ 整个代码块的价值是 -12,这是 Julia 显示的。
❹ 首先打印 -5,由于整个复合表达式也是 -5,Julia 显示了它
在第一种情况下,我们使用短路运算符 &&。然而,它需要在它的左右两边都有一个单一的表达式。在这个例子中,我们使用 begin-end 块来方便地将跨越多行的表达式序列创建成一个单一的表达式。注意,除了打印两个值之外,整个表达式返回 -12,这是链中的最后一个表达式 2 * x 的值。与 && 左侧的表达式不同,右侧的表达式不需要产生布尔值。
在第二个示例中,我们使用三元运算符。它同样需要将单个表达式传递给其所有部分才能正确工作。由于我们的代码相对较短,我们使用分号来将多个表达式组合成一个单一的表达式。我们使用括号来清楚地界定表达式链的范围。使用括号包围并不是 Julia 解析器严格要求的,但这是一个好的实践,所以我建议你总是在将多个表达式链在一起时使用它们。
总结来说,复合表达式在你需要在一个代码部分传递单个表达式但需要执行多个操作时是有用的。通常,begin-end 块用于跨越多行的长表达式,而使用分号进行链式操作则更适合单行的情况。
在实践中,你不应该过度使用复合表达式,因为它们可能会导致代码可读性降低。通常,例如,使用标准的条件表达式或定义辅助函数来提高代码清晰度会更好。然而,你很可能会在各种包的源代码中遇到复合表达式,因此了解如何解释它们是很重要的。
让我再强调一下我们在前面的代码中使用的风格约定。在 Julia 中,代码块使用四个空格进行缩进。然而,这只是一种约定。Julia 并不强制执行正确的代码格式,它也不会影响代码的执行方式,但强烈建议遵循此约定,因为它极大地提高了代码的可读性。
代码中的注释
我们经常需要源代码的一个特殊部分是注释。它们不是控制流结构,但会影响 Julia 如何解释代码,因此我在这里包含了这个说明。
如果你在代码中放入一个井号字符 (#),Julia 解析器将忽略从该字符放置位置到行尾的所有内容。
2.3.4 计算 winsorized 均值的第一种方法
现在我们已经准备好计算向量的 k 次 winsorized 均值。目前,我们将在 Julia REPL 中不使用函数,仅通过这种方式进行。让我们尝试使用你已学到的知识来计算向量 [8, 3, 1, 5, 7] 的均值。在我们的计算中,我们想要执行以下步骤:
-
初始化输入数据。向量 x 包含我们想要计算均值的数值,整数 k 表示要替换的最小和最大值的数量。
-
对向量 x 进行排序,并将结果存储在一个变量 y 中。这样,k 个最小的值就在向量 y 的开头,而 k 个最大的值就在其末尾。
-
通过使用循环将向量 y 中的 k 个最小值替换为第 (k + 1) 个最小的值。同样,将 k 个最大值替换为第 (k + 1) 个最大的值。
-
通过首先求和向量 y 的元素,然后将结果除以向量的长度来计算向量 y 的均值。得到的结果是原始向量 x 的 k 次 winsorized 均值。
首先,将变量 x 绑定到输入向量,并让 k 等于 1:
julia> x = [8, 3, 1, 5, 7]
5-element Vector{Int64}:
8
3
1
5
7
julia> k = 1
1
在最简单的方法中(我们将在第三章讨论更高级的方法),作为下一步,我们将对这个向量进行排序,并将其结果绑定到一个变量上:
julia> y = sort(x)
5-element Vector{Int64}:
1
3
5
7
8
接下来,我们将 k 个最小的值替换为第 (k + 1) 个最小的值。对于最大的值也进行同样的操作,并在变化后检查 y 向量:
julia> for i in 1:k
y[i] = y[k + 1]
y[end - i + 1] = y[end - k]
end
julia> y
5-element Vector{Int64}:
3
3
5
7
7
在这里,我们还可以看到两个新的结构。首先,当我们写 1:k 时,我们创建一个从 1 开始并包含所有整数值直到 k(包括 k)的范围(与 Python 不同,Python 中的范围最后一个元素不包括在内)。
第二个特性是向量索引。我们将在第四章中更详细地讨论这个问题,但在此,重要的是要注意以下几点:
-
Julia 中的向量使用基于 1 的索引。
-
你可以通过使用语法 x[i]来获取向量 x 的第 i 个元素。
-
作为便利,在索引时,如果你在方括号内写 end,它将被替换为向量的长度;因此,x[end]指的是向量 x 的最后一个元素。
现在,我们可以计算向量 y 的平均值:
julia> s = 0
0
julia> for v in y
global s += v
end
julia> s
25
julia> s / length(y)
5.0
到目前为止,你很可能渴望将我们的代码封装在函数中以便重用。这是下一节的主题。
2.4 定义函数
你已经知道如何使用变量和使用控制流结构,所以下一步是理解如何在 Julia 中定义函数。这是一个广泛的话题,如果你对定义和调用函数的所有细节都感兴趣,我建议查看 Julia 手册(mng.bz/vX4r)。
本节涵盖了实践中最常用的模式,这样你可以学习如何定义自己的函数来计算加权平均值。
2.4.1 使用 function 关键字定义函数
让我们从下一个列表中一个基本定义的单个位置参数函数开始。
列表 2.7 使用 function 关键字定义函数
julia> function times_two(x)
return 2 * x
end
times_two (generic function with 1 method)
julia> times_two(10)
20
此函数接受单个参数 x 并返回一个两倍大的值。正如你所看到的,定义从 function 关键字开始。接下来,我们传递一个函数名,然后是括号内其参数的列表。函数体随后。
当遇到 end 关键字时,函数定义完成。你可以使用 return 关键字来返回其后表达式的值。在不使用 return 关键字的情况下定义函数是允许的,在这种情况下,函数体中最后一个表达式的值将被返回(就像在 R 中一样)。在这本书中,我在函数中使用 return 关键字来明确表示我希望从函数中返回哪个值。
2.4.2 函数的位置和关键字参数
通常,Julia 允许你使用位置参数和关键字参数定义函数,这些参数可以可选地具有默认值。此外,一个函数可以返回多个值。以下列表显示了使用这些特性的定义示例以及函数可以调用的几种方式。
列表 2.8 使用位置和关键字参数并提供它们的默认值
julia> function compose(x, y=10; a, b=10)
return x, y, a, b
end
compose (generic function with 2 methods)
julia> compose(1, 2; a=3, b=4)
(1, 2, 3, 4)
julia> compose(1, 2; a=3)
(1, 2, 3, 10)
julia> compose(1; a=3)
(1, 10, 3, 10)
julia> compose(1)
ERROR: UndefKeywordError: keyword argument a not assigned
julia> compose(; a=3)
ERROR: MethodError: no method matching g(; a=3)
图 2.3 解释了在组合函数定义中每个参数的含义。

图 2.3 组合函数的定义
函数定义的规则如下:
-
多个位置参数(在这个例子中是 x 和 y)通过逗号分隔。当你调用函数时,你只需按它们定义的顺序传递位置参数的值(不包含它们的名称)。参数位置很重要;你可以使用赋值语法(如本例中的 y=10)为位置参数设置默认值。如果你为位置参数指定了默认值,所有随后的位置参数也必须指定默认值;换句话说,所有没有默认值的位置参数必须放在任何有默认值的位置参数之前。
-
要创建关键字参数(在这个例子中是 a 和 b),你使用分号(;)将它们与位置参数分开。当你调用一个函数时,你需要传递关键字参数的名称,然后通过等号(=)连接其值。这里也允许使用默认值。在调用函数时,你可以按任何顺序传递关键字参数。
-
如果一个参数(位置参数或关键字参数)有一个默认值,当你调用函数时可以省略传递它。
-
当调用同时接受位置参数和关键字参数的函数时,使用分号(;)分隔它们是一个好习惯,就像定义函数时一样。这是我在本书中使用的约定;然而,使用逗号也是允许的。
-
你可以省略传递具有默认值的参数(位置参数或关键字参数)的值。然而,你必须始终为所有没有指定默认值的参数传递值。
-
如果你想要从函数中返回多个值,请使用逗号(,)将它们分开。在第四章中,你将了解到技术上,Julia 会从这些值创建一个元组(Tuple)并返回它。
你可以在 Julia 手册中找到有关可选参数和关键字参数的更多信息,请参阅mng.bz/49Kv和mng.bz/QnqQ。
2.4.3 函数参数传递规则
通过等号(=)定义参数的默认值的方式也突出了 Julia 的一个重要特性。如果你向函数传递一个值,Julia 会将函数参数名称绑定到这个值,就像你执行赋值操作一样(有关变量绑定到值的讨论,请参阅第 2.2 节)。
这个特性被称为按共享传递,意味着当将参数传递给函数时,Julia 永远不会复制数据。以下是一个例子:
julia> function f!(x)
x[1] = 10
return x
end
f! (generic function with 1 method)
julia> x = [1, 2, 3]
3-element Vector{Int64}:
1
2
3
julia> f!(x)
3-element Vector{Int64}:
10
2
3
julia> x
3-element Vector{Int64}:
10
2
3
我们将这个函数命名为 f!。在函数名称后添加!作为后缀是一种风格约定,它向用户表明该函数可能会修改其参数(我们将在本章后面更详细地讨论这个约定)。
这是一种你可能从 Python 中知道的行为,但与例如 R 不同,在 R 中会执行函数参数的复制。pass-by-sharing 行为的一个好处是 Julia 中的函数调用非常快。一般来说,你可以安全地将你的代码拆分成多个函数,而不必担心它会显著降低其性能。pass-by-sharing 的缺点是,如果你将可变对象传递给一个函数(我们将在第四章中更详细地讨论可变性)并在该函数内部修改它,这种修改实际上会在函数执行完毕后发生。
2.4.4 定义简单函数的简短语法
为了定义简短函数,Julia 允许你使用一个更短的语法,该语法使用赋值运算符=。然后你可以省略函数和 end 关键字参数在定义中的使用,但有一个限制,即函数体必须是单个表达式。
以下列表展示了定义 times_two 和 compose 函数的例子,再次使用这种语法。
列表 2.9 使用赋值语法定义简短函数
julia> times_two(x) = 2 * x
times_two (generic function with 1 method)
julia> compose(x, y=10; a, b=10) = x, y, a, b
compose (generic function with 2 methods)
让我提醒你一个常见的打字错误,这个错误会显著改变代码的含义。如果你输入 times_two(x) = 2 * x,你定义了一个新的函数;然而,如果你输入 times_two(x) == 2 * x,你执行了 f(x)和 2 * x 相等性的逻辑比较。正如你所看到的,代码示例仅在=与==之间有所不同。潜在的陷阱是两者都是有效的,所以你可能不会得到你想要的结果,而 Julia 仍然会接受并执行这段代码。
在 Julia 中,函数可以作为参数传递给其他函数
Julia 的一个有用特性是函数是一等对象,就像在函数式编程中一样。因此,它们可以被传递并分配给变量,每个函数都有其独特的类型。以下是一个例子:
julia> map(times_two, [1, 2, 3])
3-element Vector{Int64}:
2
4
6
在这段代码中,我们使用了 map 函数,它接受两个参数:一个函数(在这个例子中是 times_two,之前已定义)和一个集合(在这个例子中是一个向量[1, 2, 3])。返回值是通过将 times_two 函数应用于每个元素来转换传递的集合。
2.4.5 匿名函数
当你将一个函数作为参数传递给另一个函数时,你通常会想要定义一个不需要名称的函数。你只想临时定义这个函数并将其传递给另一个函数。在 Julia 中,你可以定义无名的函数,这些函数被称为匿名函数。
语法与之前介绍的简短语法类似,不同之处在于你省略了函数名,并将=替换为->,如以下列表所示。
列表 2.10 使用->语法定义匿名函数
julia> map(x -> 2 * x, [1, 2, 3])
3-element Vector{Int64}:
2
4
6
在这个例子中,匿名函数是 x -> 2 * x。在 Python 中,等效的做法是使用具有以下语法的 lambda 函数:lambda x: 2 * x。
注意,在 x -> 2 * x 的定义中,我们省略了参数周围的括号。但通常,如果我们使用多个参数,则需要括号,就像这个定义一样:(x, y) -> x + y。
Julia 有许多接受函数作为其参数的函数。这里还有一个例子:
julia> sum(x -> x ^ 2, [1, 2, 3])
14
我们计算存储在向量中的值的平方和。在这种情况下,能够将函数用作 sum 函数的第一个参数的关键好处如下。计算向量平方和的自然方式是首先平方其元素,将结果存储在一个临时向量中,然后计算其总和。然而,这种方法成本很高,因为它需要分配这个临时向量。当执行 sum(x -> x ^ 2, [1, 2, 3]) 时,不会执行任何分配。
调用 sum 函数时的多重分派
在前面的 sum(x -> x ^ 2, [1, 2, 3]) 示例中,你看到了 Julia 使用了多重分派。我们将在第三章讨论这个主题,但我在这里简要描述它,因为它是在 Julia 中的一个基本设计概念。
通常,你将单个集合传递给 sum 函数,并返回其总和。例如,执行 sum([1, 2, 3]) 产生 6。然而,对于像前面示例中的 sum 这样的单个函数,Julia 允许你定义多个 方法。
函数的每种方法都接受不同的一组参数。当我们编写 sum([1, 2, 3]) 时,Julia 调用接受单个参数的方法。然而,当我们编写 sum(x -> x ^ 2, [1, 2, 3]) 时,sum 函数的另一种方法被调用。在这种情况下,该方法期望第一个参数是一个函数,第二个参数是一个集合,并且通过将作为第一个参数传递的函数转换这些集合的元素后,返回这些元素的求和。
2.4.6 Do 块
你应该学习的一个最后的便利语法是 do-end 块。这些块在以下情况下使用:(1) 如果你使用一个接受另一个函数作为其第一个位置参数的函数,并且(2) 你想要传递一个由多个表达式组成的匿名函数(因此标准的匿名函数定义方式不方便)。
这里是一个 do-end 块的示例:
julia> sum([1, 2, 3]) do x ❶
println("processing ", x)
return x ^ 2
end
processing 1
processing 2
processing 3
14
❶ 一个 do-end 块定义了一个接受单个参数 x 的匿名函数。这个匿名函数被作为第一个参数传递给 sum 函数。
如你所见,在这种情况下,我们使用了 sum 函数。正如我解释的那样,其中一种方法期望两个参数:第一个应该是一个函数,第二个是一个集合。当使用 do-end 语法时,你省略了想要传递给被调用函数的函数,而是添加了 do 关键字参数,后跟一个匿名函数的参数名称,你想要定义这个匿名函数。然后函数体通常像任何其他函数一样定义,并以 end 关键字参数结束。
2.4.7 Julia 中的函数命名约定
在我們結束這一節之前,讓我們討論一個與 Julia 中函數命名方式相關的傳統。你經常會看到函數名稱的末尾有一個感叹號 (!) ——例如,sort!。用戶有時會認為這意味著 Julia 以非標準的方式處理這些函數;例如,在 Rust 中,! 後綴表示一個宏。這不是真的;這樣的名稱不會得到特殊處理。
然而,一個 Julia 總結推薦開發者在它們創建的函數末尾添加 !,如果這些函數修改了他們的參數。以下是一個比較 sort 和 sort! 函數如何工作的例子。它們都返回一個排序後的集合。然而,sort 不改變它的參數,而 sort! 在原地修改它:
julia> x = [5, 1, 3, 2]
4-element Vector{Int64}:
5
1
3
2
julia> sort(x)
4-element Vector{Int64}:
1
2
3
5
julia> x
4-element Vector{Int64}:
5
1
3
2
julia> sort!(x)
4-element Vector{Int64}:
1
2
3
5
julia> x
4-element Vector{Int64}:
1
2
3
5
你可能會想知道這個傳統為什麼有價值。儘管大多數函數不會修改它們的參數,但 Julia 在將參數傳遞給函數時使用共享傳遞,所以所有函數都有可能修改它們的參數。因此,用於視覺上提醒用戶給定的函數確實利用了共享傳遞並修改了它的參數(通常在原地修改參數的優勢是提高性能)。
2.4.8 一個計算 winsorized 均值的函數的簡化定義
現在我們準備創建和測試我們計算 winsorized 均值的函數的第一個版本。我們遵循與 2.3.4 节相同的步驟,但這次,我們將代碼包裝在一個函數中:
julia> function winsorized_mean(x, k)
y = sort(x)
for i in 1:k
y[i] = y[k + 1]
y[end - i + 1] = y[end - k]
end
s = 0
for v in y
s += v
end
return s / length(y)
end
winsorized_mean (generic function with 1 method)
julia> winsorized_mean([8, 3, 1, 5, 7], 1)
5.0
在定義 winsorized_mean 函數時,與 2.3.4 节中的代碼的重要差別是這行 s += v 沒有全局前綴(在 2.3.4 节中,這行是 global s += v)。原因是這次,s 參數是局部的,因為它在函數體中定義。
現在我們已經有一個可以讓我們計算 k 次 winsorized 均值的函數。它在實踐中可以應用。我故意重用了 2.3.4 节中的步驟來展示如何將代碼包裝在函數中。然而,這個實現可以在正確性和性能兩個方面進行改進。在第三章,在你學習更多有關如何編寫 Julia 程序之後,你將看到這段代碼如何進行改進。
2.5 理解變數作用域規則
你已经学习了 Julia 语言的基礎結構。一個自然的問題是這些結構如何與變數互動。換句話說,有哪些規則允許 Julia 確定哪些變數在代碼的哪些區域是可見的?這個主題對任何程序員來說都是基礎性的,而且由於 Julia 中作用域的運作方式與 Python 等語言不同,這裡應該進行討論。
在本节中,我们不会开发 winsorized 均值函数的任何新特性(我们将在第三章中回到这个话题)。然而,在前一节中,我们已经在代码中隐式地依赖于 Julia 的变量作用域规则,因此明确解释作用域是如何工作的是很重要的。
通常,变量作用域的规则很复杂,因为它们需要涵盖许多可能的场景。本节集中讨论了处理大多数情况所需的主要概念。如果你想了解更多细节,请查看 Julia 手册(mng.bz/Xarp)。
如果你在一个代码的最高级别作用域中定义一个变量(在引入局部作用域的任何结构之外,例如函数),那么这个变量将在一个全局作用域中创建。Julia 允许用户定义全局变量,因为这通常很方便,尤其是在与 Julia REPL 交互式工作的时候。然而,使用全局变量是不被推荐的,因为它可能会对代码执行速度产生负面影响。
使用全局变量可能会对代码执行速度产生负面影响
避免使用全局变量的一个规则是 Julia 手册“性能提示”部分中列出的第一条规则之一(mng.bz/epPP)。通过 Julia 1.7,这是一个通用规则。在 Julia 1.8 中,引入了修复全局变量类型的可能性,因此从 Julia 1.8 开始,这里描述的限制仅适用于未类型化的全局变量。
让我来解释为什么全局变量会对代码执行速度产生负面影响。正如你已经学到的,Julia 在执行函数之前会编译函数。我们在第 2.2 节中也讨论过,为了确保编译结果生成快速的本地代码,函数内部使用的变量必须是类型稳定的。最后,你也知道 Julia 是动态类型的,这意味着你可以将任何类型的值绑定到一个变量上。
现在假设你在函数内部引用了一个全局变量。为了生成快速代码,Julia 必须确信变量的类型。然而,由于变量是全局的,不可能有这样的保证。因此,Julia 编译器必须假设全局变量不是类型稳定的,因此代码将会变慢。
一个关键问题是为什么 Julia 编译器在编译函数时不能确定全局变量的类型。答案是,它可以,但这个类型在 Julia 编译函数之后可能会改变。
让我给你一个例子。如第一章所述,Julia 自带对多线程的支持。这个强大的功能允许你在进行计算时使用 CPU 的所有核心。然而,这种力量是有代价的。假设你有两个并行运行的线程。在第一个线程中,你使用全局变量运行一个函数。在第二个线程中,另一个函数并行执行,并更改相同的全局变量。因此,在第一个线程中运行的函数在编译后,第二个线程中运行的函数可能会改变在第一个线程中使用的全局变量的类型。
你可能想知道如何避免在函数定义中使用全局变量引起的问题。最简单的解决方案是将这些变量作为函数参数传递。
你已经学习过的以下类型的构造(称为局部作用域)会创建一个新的作用域。在列表中,我省略了几个我们在这本书中不使用的更高级的构造):
-
函数、匿名函数、do-end 块
-
for 和 while 循环
-
try-catch-end 块(在第七章中讨论)
-
理解(在第四章中讨论)
值得注意的是,if 块和 begin-end 块不会引入新的作用域。在这些块中定义的变量会泄漏到外部作用域。
为了讨论的完整性,让我补充一下,在第三章中讨论的模块引入了一个新的全局作用域。
让我们看看这些规则在实际应用中的几个例子。启动一个新的 Julia REPL,并遵循以下代码示例。在每个示例中,我们定义一个具有略微不同的作用域行为的函数。我们从一个基本场景开始:
julia> function fun1()
x = 1
return x + 1
end
fun1 (generic function with 1 method)
julia> fun1()
2
julia> x
ERROR: UndefVarError: x not defined
这个例子表明,在函数(局部作用域)内部定义的变量如果没有在那里定义,则不会到达外部作用域。接下来,我将说明 if 块不引入新作用域的后果:
julia> function fun2()
if true
x = 10
end
return x
end
fun2 (generic function with 1 method)
julia> fun2()
10
通过执行 fun2(),你可以看到 x 变量定义在 if 块中,但由于 if 块不引入作用域,x 变量在块外也是可见的。
与 if 块不同,循环引入一个新的局部作用域。循环引入新局部作用域的最重要场景如下四个示例所示:
julia> function fun3()
x = 0
for i in [1, 2, 3]
if i == 2
x = 2
end
end
return x
end
fun3 (generic function with 1 method)
julia> fun3()
2
从 fun3()调用的结果中,你可以看到,如果我们嵌套局部作用域,并且变量 x 在外部局部作用域中定义,它将被内部局部作用域(在这种情况下由 for 循环引入)重用。如果我们省略定义中的 x = 0,函数将无法工作:
julia> function fun4()
for i in [1, 2, 3]
if i == 2
x = 2
end
end
return x
end
fun4 (generic function with 1 method)
julia> fun4()
ERROR: UndefVarError: x not defined
fun4()调用中错误的原因是 for 循环引入了一个新的局部作用域,并且由于 x 没有在 fun4 函数的外部作用域中定义,它不会从 for 循环中泄漏出来。
此外,像前一个例子中的 x 这样的循环局部变量,在循环的每次迭代中都会被重新定义,因此以下代码也会失败:
julia> function fun5()
for i in [1, 2, 3]
if i == 1
x = 1
else
x += 1
end
println(x)
end
end
fun5 (generic function with 1 method)
julia> fun5()
1
ERROR: UndefVarError: x not defined
让我们尝试理解在调用 fun5() 时代码中发生了什么。在循环的第一次迭代中,我们执行 x = 1 赋值并打印 1。在第二次迭代中,第一次迭代中的 x 被丢弃(它在每次迭代中都是新分配的),因此在尝试 x += 1 操作时其值不可用。解决这个问题的一种方法是,在 for 循环的作用域中重新引入变量 x,如下面的列表所示。
列表 2.11 更新 for 循环外部作用域中定义的局部变量
julia> function fun6()
x = 0
for i in [1, 2, 3]
if i == 1
x = 1
else
x += 1
end
println(x)
end
end
fun6 (generic function with 1 method)
julia> fun6()
1
2
3
现在当我们调用 fun6() 时,所有操作都按预期工作,因为 x 变量存储在 fun6 函数的作用域中,因此在每次迭代中不是新分配的。
无值(nothing)值
在列表 2.11 中,我们定义了一个函数 fun6,它不使用 return 关键字返回任何值。此外,函数体最后一部分是一个不产生函数在没有 return 关键字时将返回的值的 for 循环。在这种情况下,函数的返回值是 nothing,这是在不需要返回值时按照惯例使用的。
在我完成这一节之前,让我再次强调,我们在这里讨论的是 Julia 使用的简化作用域规则。关于 Julia 中作用域如何工作的所有细节都可在 Julia 手册(mng.bz/Xarp)中找到,其中还包括了对设计背后原理的解释。
摘要
-
Julia 中的每个值都有一个类型。数值类型的例子有 Int64 和 Float64。像向量这样的集合值有带参数的类型;Vector{Float64} 是一个表示可以存储 Float64 数字的向量的类型的例子。
-
Julia 是动态类型的,这意味着只有值才有类型。变量名是动态绑定到值的,这意味着通常变量可以改变绑定到它们的值的类型。
-
Julia 在变量命名方面提供了很大的灵活性。此外,Julia REPL 和常见的编辑器使得使用非标准字符(通过 LaTeX 完成补全)变得容易。
-
Julia 提供了所有标准的控制流结构。为了方便用户,它还引入了几个语法,使编写代码更加容易:三元运算符,短路评估,单表达式函数定义,匿名函数,以及 do-end 块语法。
-
在 Julia 中,你可以通过三种方式定义函数:使用函数关键字,使用赋值运算符 =,以及使用箭头运算符 -> 定义匿名函数。
-
在 Julia 中,函数和 for 以及 while 循环会引入一个新的作用域,但 if 和 begin-end 块则不会。
3 Julia 对项目扩展的支持
本章涵盖
-
使用 Julia 的类型系统
-
为函数定义多个方法
-
与模块和包一起工作
-
使用宏
在本章中,你将学习在创建大型项目时重要的 Julia 语言元素。我们从探索 Julia 的类型系统开始。了解类型层次结构的工作方式对于学习如何为单个函数定义多个方法至关重要,这是我们始于第 2.4 节讨论的话题。同样,当你使用现有的函数时,你必须知道如何找出它接受的参数类型。在调用函数时尝试传递错误类型的参数并引发异常是 Julia 中工作最常见错误之一。为了避免这些问题,你必须对 Julia 的类型系统设计有一个很好的理解。
当你为函数定义方法时,你可以限制它们接受的参数类型。这个特性使得你的 Julia 程序运行更快,更容易捕获错误,并使代码的工作方式更容易理解。
如果你的项目变得更大,你需要使用第三方功能,这些功能以包的形式提供,或者将你的源代码组织成模块。在本章中,你将学习如何使用 Julia 来实现这一点。
最后,在某些情况下,自动生成 Julia 代码是非常方便的。在 Julia 中,这是通过宏来实现的。编写自己的宏是一个高级话题,所以在本章中,你将学习如何使用 Julia 中可用的宏。
为了展示我在本章中介绍的材料在实际中的实用性,我们将改进第二章中最初实现的 winsorized_mean 函数,从性能、代码安全和可读性等方面进行优化。
3.1 理解 Julia 的类型系统
如第二章所述,第 2.4 节中实现的 winsorized_mean 函数不会与你可以传递给它的所有可能的参数值一起工作。我们如何确保它能够正确处理各种类型的传入参数?为了理解这一点,我们首先需要讨论 Julia 的类型系统。
3.1.1 Julia 中一个函数可能有多个方法
当你学习 Julia 语言时,你可能听说过它使用多重分派(在第 2.4 节中提到)。你可以为同一个函数定义多个方法,这些方法的实现根据传入参数的类型不同而不同。你可以使用 methods 函数来获取为给定函数定义的方法列表。以下是一个为设置 Julia 工作目录的 cd 函数定义的方法列表示例:
julia> methods(cd)
# 4 methods for generic function "cd":
[1] cd() in Base.Filesystem at file.jl:88
[2] cd(dir::AbstractString) in Base.Filesystem at file.jl:83
[3] cd(f::Function) in Base.Filesystem at file.jl:141
[4] cd(f::Function, dir::AbstractString) in Base.Filesystem at file.jl:91
你可以看到,一些函数的参数有类型注解;在这种情况下,它们是::Function 和::AbstractString,这限制了给定方法允许的值的类型,并根据传入值的类型改变其行为。
让我们在这里关注函数类型。直观上,所有函数都应该具有这种类型,通常情况下也是如此:
julia> sum isa Function
true
然而,如果我们检查 sum 函数的类型,我们会看到它不是 Function:
julia> typeof(sum)
typeof(sum)
julia> typeof(sum) == Function
false
要理解这里发生的事情,我们需要知道在 Julia 中,类型是按照层次结构组织的。这允许在定义函数的方法时将多个类型捆绑在一起。例如,在上一个例子中,cd 函数可以接受任何函数作为参数。
3.1.2 Julia 中的类型是按照层次结构排列的
在 Julia 中,所有类型都排列在一个树中,每个类型都有一个父类型。这个父类型,称为 超类型,可以使用 supertype 函数来检查:
julia> supertype(typeof(sum))
Function
因此,我们确实看到 sum 函数的类型是 Function 类型的 子类型。以下规则控制着类型树的工作方式(在这里我展示了主要的心智模型,并省略了一些边缘情况的讨论):
-
树的根类型称为 Any。所有其他类型都是 Any 类型的子类型。如果你定义一个函数而没有指定其参数类型,就像我们在 2.4 节中所做的那样,Julia 默认假设允许 Any 类型;也就是说,你可以向这样的函数传递任何类型的值。
-
只有叶类型才能有实例(也就是说,有特定类型的对象)。可以实例化的类型被称为 具体的。换句话说,如果你有一个值,你可以确信它的类型是具体的,并且它是一个叶类型。因此,没有类型是 Function 的函数。每个函数都有其独特的具体类型,它是 Function 类型的子类型。
-
类型树中不是叶子的类型(例如,Any 或 Function)不能被实例化。它们仅作为中间类型,允许对其他类型进行逻辑分组,并被称为 抽象的。你可以通过调用 subtypes 函数来找到抽象类型的子类型列表。
具体类型与抽象类型
只有具体类型可以被实例化,不能有具体的子类型。你可以使用 isconcretetype 函数来检查给定的类型是否是具体的。抽象类型不能有实例,但可以有子类型。你可以使用 isabstracttype 函数来检查给定的类型是否是抽象的。因此,一个类型既抽象又具体是不可能的。
然而,有些类型既不是抽象的也不是具体的。当你学习更多关于参数化类型的内容时,你将在第四章遇到这些类型。这种类型的一个例子是 Vector。(注意,这个类型省略了其参数,这就是为什么它不是具体的;在 2.1 节中,你看到了一个具有 Vector{Int} 的值的例子,这是一个具体的类型,因为它有一个完全指定的参数,在这种情况下是 Int。)
3.1.3 查找类型的所有超类型
让我们看看 supertype 和 subtypes 函数的实际应用。首先,我们从你已知的 Int64 类型开始,检查它有哪些超类型。为此,我们定义以下递归函数:
julia> function print_supertypes(T) ❶
println(T)
T == Any || print_supertypes(supertype(T))
return nothing
end
print_supertypes (generic function with 1 method)
julia> print_supertypes(Int64)
Int64
Signed
Integer
Real
Number
Any
❶ print_supertypes 函数接受一个类型作为其参数。
如您所见,类型层次结构相当深。这允许您的函数对它们接受的参数类型有精细的控制。
在我们的函数中,我们递归地遍历类型树。在这个例子中,我们从 Int64 类型开始。我们首先打印它。接下来,我们检查它是否等于 Any 类型。Int64 不等于 Any;因此,由于我们使用了||运算符,我们执行了 print_supertypes(supertype(T))表达式。它再次调用 print_supertypes 函数,这次以 Int64 的超类型作为参数,即 Signed。这个过程递归重复,直到 print_supertypes 函数以 Any 类型作为参数,即类型树的根。
在这一点上,我们不执行 print_supertypes 函数的递归调用,过程终止。图 3.1 说明了结果;箭头指示子类型关系。

图 3.1 print_supertypes 函数接受一个类型作为其参数。
此外,您可能已经注意到了我们代码中的 return nothing 行。它服务于 2.4 节中讨论的目的——即所有函数应明确指定它们想要返回的值。在这种情况下,因为我们不希望返回任何特定值,所以我们返回 nothing 值以表示函数中没有返回值。如果一个函数返回 nothing,Julia 的 REPL 不会在终端打印任何返回值。因此,在这个例子中,唯一打印的是 println(T)操作输出的类型。
3.1.4 查找类型的所有子类型
现在我们将进行相反的操作,尝试打印 Integer 抽象类型的所有子类型。以下是执行此操作的代码。在这个例子中,我们再次使用递归。这次,当类型没有子类型时,递归停止:
julia> function print_subtypes(T, indent_level=0)
println(" " ^ indent_level, T)
for S in subtypes(T)
print_subtypes(S, indent_level + 2)
end
return nothing
end
print_subtypes (generic function with 2 methods)
julia> print_subtypes(Integer)
Integer
Bool
Signed
BigInt
Int128
Int16
Int32
Int64
Int8
Unsigned
UInt128
UInt16
UInt32
UInt64
UInt8
您已经了解到整数类型有三个子类型:Bool、Signed 和 Unsigned。Bool 类型没有子类型,而 Signed 和 Unsigned 是抽象的,并且具有广泛的子类型,这些子类型在位(由类型名称中的数字表示;参见 2.1 节关于不同数值类型的位表示的讨论)中具有不同的内存占用。图 3.2 展示了这个类型层次结构。

图 3.2 Integer 类型的子类型层次结构
您可能会问,前述代码中的" " ^ indent_level 表达式是什么意思。它只是重复" "字符串 indent_level 次。第六章将详细介绍在 Julia 中处理字符串的更多细节。
3.1.5 类型联合
使用抽象类型引用类型集合是有用的。然而,有时你可能想要指定一个没有相应节点(抽象类型)的类型树中的类型列表。例如,如果你想在代码中只允许有符号或无符号整数,但不允许 Bool 值,你可以使用 Union 关键字。在我们的场景中,如果你写 Union{Signed, Unsigned},你告诉 Julia 允许 Union 关键字后面的花括号内指定的任何类型。
在数据科学工作流程中,当我们指定某种类型与 Missing 类型之间的联合时,通常会使用 Union 关键字。例如,如果你写 Union{String, Missing},你表示一个值必须是一个 String,但也可以是可选的缺失值。第七章更详细地介绍了处理缺失值。
3.1.6 决定在方法签名中放置哪些类型限制
现在我们回到 2.4 节中的 winsorized_mean 函数。它接受两个参数:一个整数 k 和一个向量 x。这些参数的适当类型限制是什么?对于 k,这很简单。根据你所学的,自然要求 k 是一个 Integer。那么 x 呢?让我们使用我们之前定义的 print_supertypes 函数检查向量 [1.0, 2.0, 3.0] 和范围 1:3 的类型和其超类型:
julia> print_supertypes(typeof([1.0, 2.0, 3.0]))
Vector{Float64}
DenseVector{Float64}
AbstractVector{Float64}
Any
julia> print_supertypes(typeof(1:3))
UnitRange{Int64}
AbstractUnitRange{Int64}
OrdinalRange{Int64, Int64}
AbstractRange{Int64}
AbstractVector{Int64}
Any
我们可以看到类型层次结构有点深,但类型似乎在 AbstractVector 层级上相遇;唯一的问题是,在第一种情况下,我们有一个 Float64 类型的参数,而在第二种情况下,是 Int64。一个直观且正确的方法是只删除参数,并要求 x 是 AbstractVector。这就是我们在 3.2 节将要做的。让我们看看 AbstractVector 是什么:
julia> AbstractVector
AbstractVector (alias for AbstractArray{T, 1} where T)
在别名解释中添加 where T 意味着 T 可以是任何类型。学习 [1.0, 2.0, 3.0] 和 1:3 的正确公共类型的一个替代方法是使用类型 join 函数:
julia> typejoin(typeof([1.0, 2.0, 3.0]), typeof(1:3))
AbstractVector{T} where T (alias for AbstractArray{T, 1} where T)
类型 join 函数找到其参数类型中最窄的父类型。你可能不会经常用到这个函数,但它在确认我们的直觉方面很有用。
与类型一起工作的主题比我们在这里所涵盖的复杂得多。我们将在第五章中回到这个话题,该章涵盖了参数类型和 where 关键字。然而,我在这本书中仍然省略了许多与类型相关的概念。在进行数据科学时,你通常不需要定义自己的类型,所以我省略了创建自己的类型、定义构造函数以及定义类型提升和转换规则的过程。关于这些主题的权威指南是 Julia 手册中的“类型”部分(docs.julialang.org/en/v1/manual/types/)。
3.2 在 Julia 中使用多重分派
现在你已经知道了如何定义函数以及类型层次结构是如何工作的,你就可以学习如何定义具有不同方法的函数了。然后你可以将这种知识应用到我们的 winsorized_mean 函数中。
3.2.1 定义函数方法规则
幸运的是,如果你理解了 Julia 类型系统的工作原理,定义方法相对简单。你只需在函数参数后添加类型限制 ::。如第 3.1 节所述,如果省略类型指定部分,Julia 假设允许 Any 类型的值。
假设我们想要通过接受单个位置参数并具有以下行为来创建函数 fun:
-
如果向 fun 传递一个数字,它应该打印“传递了一个数字”,除非它是一个具有 Float64 类型的值,在这种情况下,我们希望打印一个 Float64 值。
-
在所有其他情况下,我们希望打印“不支持类型”。
这里有一个通过为函数 fun 定义三个方法来实现这种行为的例子:
julia> fun(x) = println("unsupported type")
fun (generic function with 1 method)
julia> fun(x::Number) = println("a number was passed")
fun (generic function with 2 methods)
julia> fun(x::Float64) = println("a Float64 value")
fun (generic function with 3 methods)
julia> methods(fun)
# 3 methods for generic function "fun":
[1] fun(x::Float64) in Main at REPL[3]:1
[2] fun(x::Number) in Main at REPL[2]:1
[3] fun(x) in Main at REPL[1]:1
julia> fun("hello!")
unsupported type
julia> fun(1)
a number was passed
julia> fun(1.0)
a Float64 value
在这个例子中,1 是一个 Number(因为它是一个 Int),但它不是 Float64,例如,所以最具体的匹配方法是 fun(x::Number)。
3.2.2 方法歧义问题
当为函数定义多个方法时,你必须避免方法歧义。当 Julia 编译器无法决定给定参数集应该选择哪个方法时,就会发生歧义。
通过一个例子更容易理解这个问题。假设你想要定义一个接受两个位置参数的 bar 函数。bar 函数应该告诉你这些参数中是否有任何是数字。这是实现这个函数的第一个尝试:
julia> bar(x, y) = "no numbers passed"
bar (generic function with 1 method)
julia> bar(x::Number, y) = "first argument is a number"
bar (generic function with 2 methods)
julia> bar(x, y::Number) = "second argument is a number"
bar (generic function with 3 methods)
julia> bar("hello", "world")
"no numbers passed"
julia> bar(1, "world")
"first argument is a number"
julia> bar("hello", 2)
"second argument is a number"
julia> bar(1, 2)
ERROR: MethodError: bar(::Int64, ::Int64) is ambiguous. Candidates:
bar(x::Number, y) in Main at REPL[2]:1
bar(x, y::Number) in Main at REPL[3]:1
Possible fix, define
bar(::Number, ::Number)
如你所见,直到我们想要通过传递两个参数(第一个和第二个)都是数字来调用 bar 时,一切工作得都很顺利。在这种情况下,Julia 会抱怨它不知道应该调用哪个方法,因为可能有两个方法可以被选中。幸运的是,我们得到了如何解决这种情况的提示。我们需要定义一个额外的、解决歧义的方法:
julia> bar(x::Number, y::Number) = "both arguments are numbers"
bar (generic function with 4 methods)
julia> bar(1, 2)
"both arguments are numbers"
julia> methods(bar)
# 4 methods for generic function "bar":
[1] bar(x::Number, y::Number) in Main at REPL[8]:1
[2] bar(x::Number, y) in Main at REPL[2]:1
[3] bar(x, y::Number) in Main at REPL[3]:1
[4] bar(x, y) in Main at REPL[1]:1
多重分派有什么用?
理解 Julia 中方法的工作原理是至关重要的。正如你在前面的例子中所看到的,这种知识使用户能够根据函数任何位置参数的类型来区分函数的行为。结合第 3.1 节中讨论的灵活的类型层次系统,多重分派允许 Julia 程序员编写高度灵活和可重用的代码。
注意,通过在适当的抽象级别指定类型,用户不需要考虑所有可能的具体类型,这些类型可以传递给函数,同时仍然保留对接受值的类型的控制。例如,如果你定义了自己的 Number 子类型——例如,通过 Decimals.jl 包(github.com/JuliaMath/Decimals.jl),该包具有支持任意精度十进制浮点计算的类型——你不需要重写你的代码。新类型将正常工作,即使原始代码并不是专门针对这个用例开发的。
3.2.3 改进的 winsorized mean 实现
我们已经准备好改进 winsorized_mean 函数的定义。以下是如何比我们在 2.4 节中做得更仔细地实现它的方法:
julia> function winsorized_mean(x::AbstractVector, k::Integer)
k >= 0 || throw(ArgumentError("k must be non-negative"))
length(x) > 2 * k || throw(ArgumentError("k is too large"))
y = sort!(collect(x))
for i in 1:k
y[i] = y[k + 1]
y[end - i + 1] = y[end - k]
end
return sum(y) / length(y)
end
winsorized_mean (generic function with 1 method)
首先请注意,我们已经限制了 x 和 k 的允许类型;因此,如果你尝试调用该函数,其参数必须匹配所需的类型:
julia> winsorized_mean([8, 3, 1, 5, 7], 1)
5.0
julia> winsorized_mean(1:10, 2)
5.5
julia> winsorized_mean(1:10, "a")
ERROR: MethodError: no method matching
winsorized_mean(::UnitRange{Int64}, ::String)
Closest candidates are:
winsorized_mean(::AbstractVector{T} where T, ::Integer) at REPL[6]:1
julia> winsorized_mean(10, 1)
ERROR: MethodError: no method matching winsorized_mean(::Int64, ::Int64)
Closest candidates are:
winsorized_mean(::AbstractVector{T} where T, ::Integer) at REPL[6]:1
此外,我们还可以看到一些使代码更健壮的代码更改。首先,我们检查传递的参数是否一致;也就是说,如果 k 是负数或太大,则无效,在这种情况下,我们通过调用带有 ArgumentError 作为其参数的 throw 函数来抛出错误。看看如果我们传递错误的 k 会发生什么:
julia> winsorized_mean(1:10, -1)
ERROR: ArgumentError: k must be non-negative
julia> winsorized_mean(1:10, 5)
ERROR: ArgumentError: k is too large
接下来,在排序之前,先复制存储在 x 向量中的数据。为了实现这一点,我们使用 collect 函数,它接受任何可迭代集合,并返回一个存储相同值的对象,具有 Vector 类型。我们将这个向量传递给 sort!函数以就地排序。
你可能会问,为什么需要使用 collect 函数来分配一个新的 Vector。原因是,例如,像 1:10 这样的范围是只读的;因此,之后我们就无法通过 y[i] = y[k + 1]和 y[end - the + 1] = y[end -- k]来更新 y。此外,通常 Julia 可以支持数组中的非 1 基索引(见github.com/JuliaArrays/OffsetArrays.jl)。然而,Vector 使用 1 基索引。总之,使用 collect 函数将任何集合或通用 AbstractVector 转换为 Julia 中定义的标准 Vector 类型,该类型是可变的,并使用 1 基索引。
最后,请注意,我们不是手动执行 for 循环,而是使用了 sum 函数,这使得代码既简单又健壮。
在方法中添加参数类型注解是否会提高它们的执行速度?
你在 3.2 节中已经看到,给函数参数添加类型注解可以使 Julia 代码更容易阅读和更安全。用户经常问的一个自然问题是,这是否会提高代码执行速度。
如果你为函数只有一个方法,添加类型注解不会提高代码执行速度。原因是当函数被调用时,Julia 编译器知道你传递给它的参数类型,并使用这些信息生成本机机器代码。换句话说,类型限制信息不会影响代码生成。
然而,如果你为函数定义了多个方法,情况就不同了。这是因为类型限制会影响方法调度。然后,每个方法都可以有不同的实现,使用针对给定类型的值优化的算法。使用多重调度允许 Julia 编译器选择最适合你的数据的实现。
让我们来看一个例子。考虑第二章中引入的 sort 函数。通过调用 methods(sort),你可以了解到它在 Base Julia 中定义了五个方法(如果你加载了 Julia 包,可能还有更多)。有一个用于排序向量的通用方法,其签名是 sort(v::AbstractVector; kwthe.),还有一个用于排序范围如 1:3 的专用方法,其签名是 sort(r::Abstract- UnitRange)。
有这种专用方法的好处是什么?第二个方法定义为 sort(r::AbstractUnitRange) = r.。因为我们知道类型为 AbstractUnitRange 的对象已经是排序好的(它们是值范围,增量等于 1),所以我们只需返回传递的值。在这种情况下,利用方法签名中的类型限制可以显著提高排序操作的性能。在 3.4 节中,你将学习如何通过使用基准测试来检查这一点。
3.3 与包和模块一起工作
Julia 中的大型程序需要结构来帮助组织它们的代码。因此,很可能有人已经实现了一个像我们的 winsorized_mean 这样的函数,因为它是一种常用的统计方法。在 Julia 中,这样的函数是通过包共享的。所以,如果有人创建了一个像我们这样的函数,那么我们就不必编写自己的函数,而可以使用包中定义的那个。这就是为什么你需要知道如何在 Julia 中使用包。
3.3.1 Julia 中的模块是什么?
这个讨论的起点是理解模块的概念以及它与包和文件的关系。让我们从处理多个文件开始,因为这最容易理解。
假设你的代码被拆分成了三个文件——file1.jl、file2.jl 和 file3.jl——并且你想要创建一个主文件——比如,可以叫 main.jl——它使用这三个文件。你可以通过使用 include 函数来实现这一点。假设你的 main.jl 文件中的源代码如下:
include("file1.jl")
include("file2.jl")
include("file3.jl")
然后,如果你执行它,简化一下,它的工作方式就像你将 file1.jl 的内容复制并粘贴进去,然后复制并粘贴 file2.jl 的内容进去,最后复制并粘贴 file3.jl。正如你所看到的,include 函数的逻辑很简单。它只是允许你将代码拆分成多个文件,以使它们更小。
在 Julia 中,我刚刚展示的模式很常见。你创建一个主文件,它确实包含最小量的逻辑,并且主要作为一个包含实际代码存储的其他文件的位置。
那么,模块是什么?模块是一种定义独立的变量命名空间的方式。在 2.4 节中,我告诉你程序中有一个全局作用域。现在你将了解到可以有多个,因为每个模块定义了自己的独立的全局作用域。当你使用 Julia 时,默认的全局作用域也是一个名为 Main 的模块(因此,在本章的许多列表中,你已经看到函数是在 Main 中定义的)。
你可以使用模块关键字参数像这样定义一个名为 ExampleModule 的模块,该模块定义了一个名为 example 的单个函数:
module ExampleModule
function example()
println("Hello")
end
end # ExampleModule
你可能已经注意到了这个例子中的两个风格问题:
-
模块内的代码按照惯例不缩进(与 Julia 中所有其他块不同)。模块可以非常大(甚至跨越数千行),因此为模块的全部内容使用四个空格缩进并不实用。
-
有一个惯例是在结束关键字参数后添加一个带有模块名称的注释。再次强调,模块通常包含数百甚至数千行代码。因此,很难从视觉上识别结束关键字是否完成了模块的定义。因此,使用注释明确指出结束是有用的。
在使用 Julia 进行数据科学项目时,你通常不需要定义自己的模块,所以让我强调一些关键的实际概念:
-
与 Python 不同,模块与代码如何组织成文件没有关系。你可以在单个文件中拥有许多模块,或者一个模块可以在多个文件中定义(使用 include 函数组合)。模块仅用于通过定义单独的变量命名空间和模块特定的全局作用域,为你的代码提供逻辑结构。
-
模块设计者可以使用 export 关键字决定哪些变量和函数对模块用户是可用的。
如果有人创建了一个打算与其他 Julia 用户共享的模块,它可以通过 Julia 通用注册表(github.com/JuliaRegistries/General)进行注册。这些模块必须具有特殊结构,注册后,它们作为 包 可用。你可以在附录 A 中找到管理包的说明。
简单来说,你可以这样理解模块和包。模块赋予你将代码组织成连贯单元的能力。当开发者决定将模块提供的功能与其他 Julia 用户共享时,这个模块可以通过适当的元数据(如版本)进行标注,并注册为包。你可以在 Pkg.jl 包文档中找到有关包创建、开发和管理的详细信息,文档地址为 pkgdocs.julialang.org/v1/。
Julia 的标准库
通常,当你想要使用一个包时,你需要安装它(安装将在附录 A 中解释)。然而,Julia 随附了一套标准库模块。它们的行为类似于常规的 Julia 包,但你不需要明确安装它们。我们在这个章节中使用的此类模块的例子是 Statistics。你可以在 Julia 手册的“标准库”部分找到所有标准库模块的文档(docs.julialang.org/en/v1/)。
3.3.2 在 Julia 中如何使用包?
了解如何使用捆绑到包中的模块对于数据科学家来说很重要。你有两种基本方法可以使已安装包的功能在你的代码中使用:使用 import 或 using 关键字参数。当你使用 import 时,只有模块名称被引入到你的代码作用域中。要访问模块定义的变量和函数,你需要用模块名称作为前缀,后面跟着一个点。以下是一个例子:
julia> import Statistics
julia> x = [1, 2, 3]
3-element Vector{Int64}:
1
2
3
julia> mean(x)
ERROR: UndefVarError: mean not defined
julia> Statistics.mean(x)
2.0
Statistics 模块是 Julia 标准库的一部分。它定义了基本统计函数,如 mean、std、var 和 quantile。正如你所见,当我们使用 import 时,我们必须在函数名称前加上 Statistics 以使一切正常工作。
相反,使用 using 关键字,我们将模块的所有导出功能引入到作用域中,以便可以直接使用。因此,根据前面的例子,我们有以下内容:
julia> using Statistics
julia> mean(x)
2.0
这段代码可以正常工作,因为 mean 函数是由 Statistics 模块导出的。
现在,你可能想知道在代码中你应该使用 import 还是 using 语句。这是 Python 用户经常问的问题,他们了解到只导入计划在代码中使用的函数或变量是安全的。在 Julia 中情况并非如此。
在大多数 Julia 代码中,你可以安全地使用 using 语句,这也是人们通常的做法。你已经知道原因:Julia 语言可以自动检测你试图使用的名称是否与已通过例如 using 关键字引入的相同名称冲突。在这种情况下,你会被告知存在问题。
让我通过一些最常见的情况来解释你可能会遇到名称冲突问题。在第一个例子中,你定义了一个变量名,后来通过 using 语句从模块中引入。为此,请启动一个新的 Julia 会话:
julia> mean = 1
1
julia> using Statistics
WARNING: using Statistics.mean in module Main conflicts with
an existing identifier.
julia> mean
1
如你所见,因为你已经定义了 mean 变量,加载导出 mean 函数的 Statistics 模块会产生警告,但不会覆盖你的定义。你必须使用带有前缀的形式从这个模块中调用 mean 函数——即 Statistics.mean。
在第二种情况下,你试图将一个与已加载模块中的函数名称冲突的变量名赋值(再次启动一个新的 Julia 会话):
julia> using Statistics
julia> mean([1, 2, 3])
2.0
julia> mean = 1
ERROR: cannot assign a value to variable Statistics.mean from module Main
这次,你得到了一个错误;从你使用 Statistics 模块中的 mean 函数的那一刻起,你就不允许在代码中为其赋值。
在最后一种情况下,你首先加载一个模块,然后在使用模块中定义的相同名称之前定义一个冲突的变量名(再次启动一个新的 Julia 会话):
julia> using Statistics
julia> mean = 1
1
julia> mean([1, 2, 3])
ERROR: MethodError: objects of type Int64 are not callable
现在,您被允许自由定义均值变量而不会收到警告。稍后,如果您想使用来自 Statistics 模块的 mean 函数,您又需要再次编写 Statistics.mean。为了方便起见,在这种情况下,您可以在全局作用域中定义变量而不会出现错误或警告。如果您永远不会计划使用从已加载的模块中加载的某个名称,则该名称不会被引入作用域。这在您已经有了一些正在运行的代码并需要开始使用一个导出您已在代码中使用名称的额外模块时非常有用。
在这种情况下,这种行为确保您不需要更改您的原始代码;它将像以前一样继续工作。您可以建立的心理模型是 Julia 是懒惰的;它在第一次使用时将变量引入作用域并解析其名称。
3.3.3 使用 StatsBase.jl 计算 winsorized 均值
现在我们准备回到我们的 winsorized_mean 示例。假设您已安装了 StatsBase.jl 包,您会发现它提供了 winsor 函数。在加载 Statistics 和 StatsBase 之后,您可以检查其帮助信息(启动一个新的 Julia 会话):
julia> using Statistics
julia> using StatsBase
help?> winsor
search: winsor winsor! winsorized_mean
winsor(x::AbstractVector; prop=0.0, count=0)
Return an iterator of all elements of x that replaces either count or
proportion prop of the highest elements with the previous-highest
element and an equal number of the lowest elements with the next-lowest
element.
The number of replaced elements could be smaller than specified if
several elements equal the lower or upper bound.
To compute the Winsorized mean of x, use mean(winsor(x)).
让我们检查它是否确实产生了与我们的数据中的 winsorized_mean 相同的结果:
julia> mean(winsor([8, 3, 1, 5, 7], count=1))
5.0
为什么您需要重启您的 Julia 会话?
在本节中的几个示例中,我已提示您启动一个新的 Julia 会话。这是因为目前无法在定义了变量或函数后完全重置工作空间。例如,正如您在示例中看到的,在我们使用了来自 Statistics 模块的 mean 函数之后,我们不允许创建一个具有 mean 名称的变量。
由于用户在交互式工作时经常需要此功能,Julia 开发团队计划在未来添加清除工作空间而不重启 Julia 会话的功能。
如同往常,如果您想了解更多关于模块的详细信息,请参阅 Julia 手册 (docs.julialang.org/en/v1/manual/modules/)。有关如何创建包以及 Julia 包管理器如何工作的详细信息,请参阅 Pkg.jl 包文档 (pkgdocs.julialang.org/v1/)。附录 A 解释了如何在 Julia 中安装包以及如何获取它们功能方面的帮助。
在讨论模块和包的总结中,讨论 Julia 社区管理其功能的标准非常重要。就提供的功能和方法而言,Julia 的设计遵循与 Python 的“电池包含”方法相似的原则:
-
默认情况下,您只能访问在 Base 模块中定义的一组非常有限的函数,该模块在启动 Julia 时始终被加载。
-
Julia 随带了许多预安装的包,这些包构成了 Julia 标准库,你可以在需要时加载。这些模块提供了诸如字符串处理、处理日期和时间、多线程和分布式计算、I/O、排序、基本统计、随机数生成、线性代数、Julia 对象序列化和测试等功能。
如果你需要标准库中不可用的功能,最简单的方法是查找它所在的包。JuliaHub (juliahub.com/ui/Packages) 提供了一个灵活的网页界面,允许你浏览可用的包。
Base Julia 术语的含义
通常,在这本书以及其他关于 Julia 的资源中,你会看到术语 Base Julia。这指的是 Julia 定义的 Base 模块。此模块提供了一组定义,当你在运行 Julia 时,这些定义总是被加载。
使用 Julia 进行统计分析
Julia 随带 Statistics 模块作为其标准库的一部分。此模块包含基本统计功能,允许你计算数据的均值、方差、标准差、皮尔逊相关、协方差、中位数和分位数。
JuliaStats 收集中的更多高级统计功能提供了。本章讨论的 StatsBase.jl 包是 JuliaStats 的一部分。此包定义了允许你计算数据加权统计函数,并提供诸如排名和秩相关以及各种数据抽样算法的功能。
JuliaStats 的其他流行包包括 Distributions.jl(提供各种概率分布的支持)、HypothesisTests.jl(定义了许多常用的统计测试)、MultivariateStats.jl(用于多元统计分析,如主成分分析)、Distances.jl(用于高效计算向量之间的距离)、KernelDensity.jl(用于核密度估计)、Clustering.jl(提供数据聚类算法)和 GLM.jl(允许你估计广义线性模型)。
3.4 使用宏
你将在本书中遇到 Julia 的最后一个重要特性是宏。作为一名数据科学家,你可能不需要定义自己的宏,但预期你会经常使用它们,尤其是在第二部分,我们将讨论 DataFramesMeta.jl 包中定义的特定领域语言(DSL),它允许方便地处理数据框。
在我们当前的目的下,我们需要使用 @time 宏来比较我们的 winzorized_mean 函数与 StatsBase.jl 包提供的实现之间的性能。
那么,宏到底做了什么?宏用于生成你的程序代码。你可以把宏看作是接受 Julia 代码的解析表示并返回其转换(技术上,宏在 抽象语法树 的层面上操作 [mng.bz/5mKZ])的函数。
重要的是要理解,宏是在 Julia 代码解析之后、编译之前执行的。如果你了解 Lisp 编程语言,你会注意到 Julia 和 Lisp 在支持宏的方式上有相似之处。请注意,在 Julia 中,宏与执行源代码文本操作的 C 宏不同。
你可以很容易地在代码中识别宏调用,因为宏总是以 @ 字符为前缀。以下是一个宏调用的例子:
julia> @time 1 + 2
0.000000 seconds
3
在这个例子中,我们使用 @time 宏并将 1 + 2 表达式传递给它。这个宏执行传递的表达式并打印出执行所需的时间。如你所见,与函数不同,你可以不使用括号来调用宏。然而,你也可以将传递给宏的表达式用括号括起来:
julia> @time(1 + 2)
0.000000 seconds
3
这里是一个调用接受两个参数的宏的例子:
julia> @assert 1 == 2 "1 is not equal 2"
ERROR: AssertionError: 1 is not equal 2
julia> @assert(1 == 2, "1 is not equal 2")
ERROR: AssertionError: 1 is not equal 2
注意,如果你不使用括号,传递给宏的表达式应该用空格分隔(在这种情况下不能使用逗号)。
你现在知道了宏是如何被调用的,但它们到底做了什么?正如我所说的,它们重写你的代码以生成新的、转换后的代码。你可以通过使用 @macroexpand 宏轻松地看到这段重写的代码。让我们从一个简单的 @assert 宏的例子开始:
julia> @macroexpand @assert(1 == 2, "1 is not equal 2")
:(if 1 == 2
nothing
else
Base.throw(Base.AssertionError("1 is not equal 2"))
end)
如你所见,在这种情况下,生成的代码相对简单。@assert 宏创建了一个 if 块,如果断言为真则什么都不做,如果断言为假则抛出错误。
当然,通常宏可以生成更复杂的代码。例如,@time 宏执行多个操作以确保正确测量传递表达式的执行时间:
julia> @macroexpand @time 1 + 2
quote
#= timing.jl:206 =#
while false
#= timing.jl:206 =#
end
#= timing.jl:207 =#
local var"#11#stats" = Base.gc_num()
#= timing.jl:208 =#
local var"#14#compile_elapsedtime" =
Base.cumulative_compile_time_ns_before()
#= timing.jl:209 =#
local var"#13#elapsedtime" = Base.time_ns()
#= timing.jl:210 =#
local var"#12#val" = 1 + 2
#= timing.jl:211 =#
var"#13#elapsedtime" = Base.time_ns() - var"#13#elapsedtime"
#= timing.jl:212 =#
var"#14#compile_elapsedtime" =
Base.cumulative_compile_time_ns_after() - var"#14#compile_elapsedtime"
#= timing.jl:213 =#
local var"#15#diff" = Base.GC_Diff(Base.gc_num(), var"#11#stats")
#= timing.jl:214 =#
Base.time_print(var"#13#elapsedtime", (var"#15#diff").allocd,
(var"#15#diff").total_time, Base.gc_alloc_count(var"#15#diff"),
var"#14#compile_elapsedtime", true)
#= timing.jl:215 =#
var"#12#val"
end
如你所见,看似简单的测量执行时间的操作实际上相当复杂。
现在你可能会问为什么 @time 是一个宏而不是一个函数。如果你定义的是 time 函数而不是宏,并编写 time(1 + 2),那么 1 + 2 表达式会在传递给函数之前被评估,因此无法测量其执行时间。为了测量一个表达式的执行时间,我们必须在表达式运行之前增加适当的代码。这只有在解析 Julia 代码时才可能实现。
值得记住的是 @macroexpand 宏,因为你在学习 DataFramesMeta.jl 包的第二部分时会发现它很有用。
如本章惯例,让我们使用 winsorized_mean 示例来测试宏。我们将比较我们的解决方案与 StatsBase.jl 实现的性能。对于基准测试,我们将使用 BenchmarkTools.jl 包中的@benchmark 宏。它与@time 宏的不同之处在于它多次运行表达式,然后计算观察到的运行时间的统计数据(在运行此代码之前,请使用第 3.2 节中的代码定义 winsorized_mean 函数)。在示例代码中,我在 rand(10⁶)表达式后添加了一个分号(;),以抑制将其值打印到终端。
我们从基准测试我们的 winsorized_mean 函数开始:
julia> using BenchmarkTools
julia> x = rand(10⁶); ❶
julia> @benchmark winsorized_mean($x, 10⁵) ❷
❶ 在 REPL 传入的表达式末尾使用分号(;)来抑制将其值打印到终端
❷ 由于 x 是一个全局变量,使用$x 来确保测试代码的正确基准测试
您应该得到与图 3.3 中所示类似的计时结果(在您的机器上,精确的计时可能略有不同)。

图 3.3 winsorized_mean 函数的执行时间基准测试
现在我们使用 Julia 统计生态系统中的包提供的函数来基准测试 winsorized mean 的计算:
julia> using Statistics
julia> using StatsBase
julia> @benchmark mean(winsor($x; count=10⁵))
代码产生了图 3.4 中所示的计时结果。使用库函数要快得多。

图 3.4 使用 winsor 函数的执行时间基准测试
在示例中,我们首先使用 rand 函数从范围[0, 1]生成一百万个随机浮点数。基准测试的结果表明,库函数的速度大约是我们代码的四倍。这个原因相对容易猜测。在我们的函数中,我们排序整个向量,而通常情况下,由于 k 与向量大小相比通常相对较小,所以不需要这样做。库解决方案使用 partialsort!函数来提高其效率。
使用@benchmark 宏的一个重要方面是,我们使用\(x 而不是仅仅使用 x。这是为了正确评估我们检查的表达式的执行时间。作为规则,请记住,在您想要基准测试的表达式中使用\)前缀来标记所有全局变量(这仅适用于基准测试,并不是使用宏时的通用规则)。有关此要求的详细信息,请参阅 BenchmarkTools.jl 包的文档(github.com/JuliaCI/BenchmarkTools.jl)。简短的解释如下。回想一下,由于 x 是一个全局变量,使用它的代码不是类型稳定的。当@benchmark 宏看到$x 时,它被指示在运行基准测试之前将 x 变量转换为局部变量(因此是类型稳定的)。
BenchmarkTools.jl 包也提供了一个接受与 @benchmark 相同参数的 @btime 宏。区别在于它产生的输出更简洁,类似于 @time,并且打印的时间是基准测试期间测量的最小时间。以下是一个示例:
julia> @btime mean(winsor($x; count=10⁵))
12.542 ms (2 allocations: 7.63 MiB)
0.5003188657625405
注意,报告的时间与 @benchmark mean(winsor($x; count=10⁵)) 生成的最小时间相似。
作为应用宏的最后一个例子,尝试在你的 Julia REPL 中写下以下内容:
julia> @edit winsor(x, count=10⁵)
@edit 是我最喜欢的宏之一。在你的源代码编辑器中,它会直接带你到你所使用函数的源代码(你可以通过设置 JULIA_EDITOR 环境变量来指定应该使用哪个编辑器;参见 mng.bz/yaJy)。使用 Julia 的一个巨大好处是,这个函数很可能是用 Julia 编写的,因此你可以轻松地检查其实现。我建议你检查 winsor 函数的实现,以了解其创造者使用了哪些技巧来使其运行得更快。
练习 3.1 创建一个名为 x 的变量,其值为从 1 到 10⁶ 的范围。现在,使用 collect 函数创建一个 y 向量,其包含与 x 范围相同的值。使用 @btime 宏,通过使用 sort 函数来检查对 x 和 y 进行排序的时间。最后,使用 @edit 宏,检查在排序 x 范围时将被调用的 sort 函数的实现。
这就是你需要了解关于宏的所有内容,以便使用它们。你几乎不需要编写自己的宏,因为大多数时候,编写函数就足以得到你想要的结果。然而,有时你希望在代码执行之前执行某些操作。在这些情况下,宏是实现预期结果的方法。
概述
-
变量的类型之间存在层次关系,并形成一个树状结构。树的根是
Any类型,它可以匹配任何值。具有子类型的类型被称为 抽象 类型,不能有实例。可以有实例的类型不能有子类型,被称为 具体 类型。 -
一个函数可以有多个方法。每个方法都有一个独特的参数类型集,它允许使用这些参数。
-
在 Julia 中,模块用于创建独立的命名空间(全局作用域)。模块最常见的使用是创建包。包可以注册到 Julia 通用注册表中,并供所有开发者使用。
-
Julia 中的宏允许你在代码执行之前将其转换为其他代码。当函数不允许你达到预期结果时,它们有时很有用。
-
当你安装 Julia 时,它“内置”了许多模块。这些模块作为标准库的一部分提供,并提供了在实践中最常需要的功能。你可以在 JuliaHub 中探索 Julia 生态系统中的额外包。
4 在 Julia 中处理集合
本章涵盖
-
与数组一起工作
-
使用字典来处理键值映射
-
处理不可变集合类型:元组和命名元组
在第二章和第三章中,你学习了 Julia 语言的基本元素。我们一直在所有示例中主要使用标量类型(如数字)。然而,在数据科学中,你通常会处理数据 集合,即一组变量数量的数据项。在第二章中已经介绍的一种集合类型是向量。
在本章中,你将学习如何使用在实际情况中最常用的几个基本集合:数组、字典、元组和命名元组。
4.1 与数组一起工作
在本节中,你将学习在 Julia 中处理数组的基础知识:它们的创建、对数组的索引以及你可以期望对这些数组执行的最常见操作。数组是数据科学中常用的集合。大多数机器学习算法都期望以数组形式存储数据作为它们的输入。在 Julia(与例如 Python 相比),数组是语言规范的一部分,因此它们配备了方便的语法。与它们一起工作只需要学习一套规则,而且它们运行速度快。
要学习如何在 Julia 中处理数组,我们将分析安斯康姆四重奏数据(mng.bz/69ZZ)。正如你将在本节中学到的,它由四个数据集组成,这些数据集具有相同的基本描述性统计,但分布却非常不同。每个数据集包含 11 个观测值,有两个变量:一个表示为 x 的特征和一个表示为 y 的目标。表 4.1 显示了数据。
表 4.1 安斯康姆四重奏数据
| 数据集 1 | 数据集 2 | 数据集 3 | 数据集 4 |
|---|---|---|---|
| x | y | x | y |
| 10.0 | 8.04 | 10.0 | 9.14 |
| 8.0 | 6.95 | 8.0 | 8.14 |
| 13.0 | 7.58 | 13.0 | 8.74 |
| 9.0 | 8.81 | 9.0 | 8.77 |
| 11.0 | 8.33 | 11.0 | 9.26 |
| 14.0 | 9.96 | 14.0 | 8.10 |
| 6.0 | 7.24 | 6.0 | 6.13 |
| 4.0 | 4.26 | 4.0 | 3.10 |
| 12.0 | 10.84 | 12.0 | 9.13 |
| 7.0 | 4.82 | 7.0 | 7.26 |
| 5.0 | 5.68 | 5.0 | 4.74 |
我们的目标是对这些数据集执行以下操作:
-
计算变量 x 和 y 的平均值和标准差
-
计算变量 x 和 y 的皮尔逊相关系数
-
通过 x 解释 y 并计算其确定系数 R²
-
通过使用图表来直观地调查数据
书中用于表格数据的术语
在本书中描述表格数据时,我使用了以下术语。数据行被称为观测值,列被称为变量。
在预测模型的上下文中,由模型解释的变量被称为目标(其他交替使用的名称包括输出或因变量)。用于进行预测的变量被称为特征(其他名称包括输入或自变量)。
在 MLJ.jl 生态系统(github.com/alan-turing-institute/MLJ.jl)中使用了相同的术语,这是一个在 Julia 中用于机器学习的流行工具箱。
4.1.1 将数据放入矩阵中
我们想要分析存储在表 4.1 中的数据。该表有八个列和 11 行。每一列代表一个变量。请注意,列 1、3、5 和 7(奇数列)分别是数据集 1、2、3 和 4 中的 x 特征变量。同样,列 2、4、6 和 8(偶数列)是相应数据集中的 y 目标变量。
创建矩阵
在分析数据之前,我们需要将其存储在计算机的内存中。由于数据是同质的类型(这些都是数字),使用矩阵作为容器是自然的。在本节中,你将看到如何创建这个矩阵并检查其基本属性。
我们从创建一个变量并将其绑定到存储我们数据的矩阵开始,如下所示。
列表 4.1 定义存储 Anscombe 的四重奏数据的矩阵
julia> aq = [10.0 8.04 10.0 9.14 10.0 7.46 8.0 6.58
8.0 6.95 8.0 8.14 8.0 6.77 8.0 5.76
13.0 7.58 13.0 8.74 13.0 12.74 8.0 7.71
9.0 8.81 9.0 8.77 9.0 7.11 8.0 8.84
11.0 8.33 11.0 9.26 11.0 7.81 8.0 8.47
14.0 9.96 14.0 8.1 14.0 8.84 8.0 7.04
6.0 7.24 6.0 6.13 6.0 6.08 8.0 5.25
4.0 4.26 4.0 3.1 4.0 5.39 19.0 12.50
12.0 10.84 12.0 9.13 12.0 8.15 8.0 5.56
7.0 4.82 7.0 7.26 7.0 6.42 8.0 7.91
5.0 5.68 5.0 4.74 5.0 5.73 8.0 6.89]
11×8 Matrix{Float64}:
10.0 8.04 10.0 9.14 10.0 7.46 8.0 6.58
8.0 6.95 8.0 8.14 8.0 6.77 8.0 5.76
13.0 7.58 13.0 8.74 13.0 12.74 8.0 7.71
9.0 8.81 9.0 8.77 9.0 7.11 8.0 8.84
11.0 8.33 11.0 9.26 11.0 7.81 8.0 8.47
14.0 9.96 14.0 8.1 14.0 8.84 8.0 7.04
6.0 7.24 6.0 6.13 6.0 6.08 8.0 5.25
4.0 4.26 4.0 3.1 4.0 5.39 19.0 12.5
12.0 10.84 12.0 9.13 12.0 8.15 8.0 5.56
7.0 4.82 7.0 7.26 7.0 6.42 8.0 7.91
5.0 5.68 5.0 4.74 5.0 5.73 8.0 6.89
aq 变量是一个包含 Float64 值的 Matrix。请注意,在 Julia 中,创建存储预定义数据的矩阵很容易。你只需将数据的一行作为输入的单行写入,使用空白作为列的分隔符,并用方括号将一切括起来。如果你想要了解构建数组的其他选项,请参阅 Julia 手册中的“数组字面量”部分(mng.bz/M0vo)。
在列表 4.1 中我们操作输出的头部,我们可以看到矩阵有 11 行和 8 列。我们可以通过使用 size 函数来检查这一点:
julia> size(aq)
(11, 8)
julia> size(aq, 1)
11
julia> size(aq, 2)
8
size 函数可以接受一个参数,在这种情况下,它返回一个维度的元组,或者接受两个参数,其中第二个参数是我们想要调查的维度(其中 1 代表行,2 代表列)。
与元组一起工作
在继续前进之前,让我们简要讨论一下什么是tuple。你可以把它想象成一个向量,但长度固定且不可变。它是使用括号创建的,而向量是使用方括号创建的。你可以像向量一样获取元组的元素,但与向量不同,你不能设置它们,因为元组是不可变的;参见图 4.1。Julia 中的元组与 Python 中的元组类似,它们的类型是 Tuple。

图 4.1 向量和元组的比较。你可以获取向量和元组的元素,但 Julia 只允许设置向量的元素。
图 4.1 中所示操作的执行结果,在 Julia REPL 中如下所示:
julia> v = [1, 2, 3]
3-element Vector{Int64}:
1
2
3
julia> t = (1, 2, 3)
(1, 2, 3)
julia> v[1]
1
julia> t[1]
1
julia> v[1] = 10
10
julia> v
3-element Vector{Int64}:
10
2
3
julia> t[1] = 10
ERROR: MethodError: no method matching
setindex!(::Tuple{Int64, Int64, Int64}, ::Int64, ::Int64)
在这个例子中,请注意,向量和元组都使用 基于 1 的索引。这意味着,正如在第二章中讨论的,向量和元组的第一个元素的索引为 1。在 R、Fortran 和 MATLAB 中也使用相同的约定。如果你大量使用 Python、Java 或 C++,这一点尤其重要,因为这些编程语言使用基于 0 的索引。
元组与向量的比较
你可能会问使用元组而不是向量的好处是什么。考虑如下。
元组是不可变的,因此如果你想在代码中确保用户无法更改它们,使用它们会更安全。
由于元组是不可变的,因此它们更快,因为编译器不需要使用动态内存分配来处理它们(在类型稳定的代码中),并且能够知道存储在其中的变量的类型,即使它们是异构的(有关确保 Julia 代码性能的提示列表,请参阅 Julia 手册,mng.bz/epPP)。
作为缺点,我不建议创建存储大量元素的元组(它们最适合存储小集合)。大元组可能会导致你的程序编译时间显著增加。
Julia 中向量的表示
在本节中,我们讨论的是 Julia 中使用的一种基本向量类型,它具有 Vector 类型。一般来说,Julia 支持其他向量类型,你将在接下来的章节中了解到其中的一些。特别是,了解以下内容是有用的:与 Vector 类型不同,某些向量类型是不可变的或不需要基于 1 的索引。
对于更技术性的读者,让我提一下,在 Julia 中,元组是在栈上分配的,而标准数组是在堆上分配的。如果你不知道这些内存分配模型,请参阅 mng.bz/o5a2。为了有效地使用 Julia,你不需要知道内存管理是如何处理的细节。只需了解堆分配比栈分配慢即可。此外,堆分配需要运行一个称为 垃圾回收 (GC) 的额外过程。GC 负责释放不再被引用的堆分配内存。
图 4.2 展示了创建元组与向量的基准测试。你可以在内存估计部分(用矩形标记)看到,创建向量需要一次内存分配,而创建元组不会导致任何分配。因此,在 GC 部分(用圆角矩形标记),你可以看到,在基准测试元组创建时,GC 从未触发,而在基准测试向量时,GC 有时会运行。

图 4.2 比较元组与向量创建时间的基准测试。创建元组更快,并且不会导致堆内存分配。在执行此图所示的计算之前,请使用 BenchmarkTools 运行。
为了总结向量和元组的比较,让我们讨论当传递混合类型的数据时它们的构造。当你使用方括号构造一个向量时,Julia 会尝试将所有传递的元素提升到公共类型,而构造元组不会导致这种转换。以下是一个例子:
julia> [1, 2.0]
2-element Vector{Float64}:
1.0
2.0
julia> (1, 2.0)
(1, 2.0)
在代码中,当构造向量时,我们传递 1(一个整数)和 2.0(一个浮点值)。在产生的向量中,整数 1 被转换为浮点数 1.0。当构造元组时,传递的值被存储在其中而不进行任何转换。
4.1.2 计算矩阵中存储的数据的基本统计量
现在我们已经准备好计算存储在 aq 矩阵中的变量的均值和标准差。为此,我们将使用 Statistics 模块中定义的 mean 和 std 函数:
julia> using Statistics
julia> mean(aq; dims=1)
1×8 Matrix{Float64}:
9.0 7.50091 9.0 7.50091 9.0 7.5 9.0 7.50091
julia> std(aq; dims=1)
1×8 Matrix{Float64}:
3.31662 2.03157 3.31662 2.03166 3.31662 2.03042 3.31662 2.03058
在第 4.1 列出的 aq 矩阵中,第 1、3、5 和 7 列存储 x 特征。在所提供的摘要中,无论是均值函数还是标准差函数,位置 1、3、5 和 7 的值都是相等的。这意味着在这些所有情况下,x 特征具有相同的均值和标准差。对于存储 y 目标变量的第 2、4、6 和 8 列,也存在相同的情况。
注意,我们使用了 dims 关键字参数来指示我们想要计算统计量的维度。这里 dims=1,因为我们有按行存储的观测值,所以我们想要在 aq 矩阵的第一维上计算统计量。换句话说,我们按列计算 aq 矩阵的统计量,因为我们想要分析的数据存储为其列。
让我们讨论两种计算所需统计量的替代方法。这是第一种方法:
julia> map(mean, eachcol(aq))
8-element Vector{Float64}:
9.0
7.500909090909093
9.0
7.500909090909091
9.0
7.500000000000001
9.0
7.50090909090909
julia> map(std, eachcol(aq))
8-element Vector{Float64}:
3.3166247903554
2.031568135925815
3.3166247903554
2.0316567355016177
3.3166247903554
2.030423601123667
3.3166247903554
2.0305785113876023
让我们分析这个例子。eachcol(aq)调用返回一个迭代矩阵列的集合(作为参考,eachrow(aq)将迭代其行)。接下来,我们应用 map 函数(在第二章中讨论过),它将适当的函数(均值和标准差,分别)应用于每一列。作为第二章的提醒,请注意,我们可以像这样使用 do-end 符号与 map 函数:
map(eachcol(aq)) do col
mean(col)
end
然而,在这种情况下,这会比将 mean 函数作为 map 的第一个位置参数传递更冗长。
而不是使用 map 函数,我们可以使用理解来通过迭代 aq 矩阵的列创建一个向量:
julia> [mean(col) for col in eachcol(aq)]
8-element Vector{Float64}:
9.0
7.500909090909093
9.0
7.500909090909091
9.0
7.500000000000001
9.0
7.50090909090909
julia> [std(col) for col in eachcol(aq)]
8-element Vector{Float64}:
3.3166247903554
2.031568135925815
3.3166247903554
2.0316567355016177
3.3166247903554
2.030423601123667
3.3166247903554
2.0305785113876023
如您所见,理解使用了 for 关键字参数,之后我们指定哪个变量(在这种情况下为 col)应存储由迭代器产生的值(在这种情况下为 eachcol(aq))。然后在 for 关键字之前,我们写出应该评估的表达式,它可以依赖于 col 变量。结果,我们得到一个收集产生的结果的数组。图 4.3 比较了使用 map 函数和理解时的语法。

图 4.3 使用 map 函数和列表推导式时的语法。当使用列表推导式时,您明确地为用于存储迭代值的变量命名(在我们的例子中是 col)。
在大多数情况下,使用列表推导式和使用 map 函数之间的选择取决于程序员的便利性和代码可读性(特别是,您可以期望相似的性能)。它们的区别在您想要同时操作几个集合时最为明显。请参阅 Julia 手册 mng.bz/aPZo 和 mng.bz/gR1x 以获取示例。另一个区别是,列表推导式始终产生数组,而 map 函数可以产生不同类型的值。以下是一个示例,展示如何取存储在元组中的几个数字的绝对值。列表推导式产生一个向量,而 map 返回一个元组:
julia> x = (-2, -1, 0, 1, 2)
(-2, -1, 0, 1, 2)
julia> [abs(v) for v in x]
5-element Vector{Int64}:
2
1
0
1
2
julia> map(abs, x)
(2, 1, 0, 1, 2)
4.1.3 数组索引
通常,您想选择矩阵的一部分以便以后使用它。这可以通过索引轻松完成。
我们通过展示另一种指定计算 aq 矩阵中列统计的方法来说明索引:
julia> [mean(aq[:, j]) for j in axes(aq, 2)]
8-element Vector{Float64}:
9.0
7.500909090909093
9.0
7.500909090909091
9.0
7.500000000000001
9.0
7.50090909090909
julia> [std(aq[:, j]) for j in axes(aq, 2)]
8-element Vector{Float64}:
3.3166247903554
2.031568135925815
3.3166247903554
2.0316567355016177
3.3166247903554
2.030423601123667
3.3166247903554
2.0305785113876023
这次,我们在 aq 矩阵中使用索引。axes 函数与之前讨论的 size 函数类似。区别在于它不是返回给定维度的长度,而是在给定维度中产生一个有效的索引范围。在这个例子中,如下所示:
julia> axes(aq, 2)
Base.OneTo(8)
help?> Base.OneTo
Base.OneTo(n)
Define an AbstractUnitRange that behaves like 1:n, with the added
Distinction that the lower limit is guaranteed (by the type system)
to be 1.
如您所见,索引从 1 开始,跨越到 8。我包括了返回的 OneTo 对象的文档,以便您确切了解它代表什么。在实践中,您不需要自己构建它,但偶尔您可能会遇到由标准 Julia 函数产生的它,因此了解它的作用是有价值的。
为什么 OneTo 以 Base 开头?
我们可以看到,Julia 通过在前面加上 Base 来打印 OneTo 类型的信息——例如,Base.OneTo(8)。这个输出给我们提供了两条信息:
-
OneTo 类型定义在 Base 模块中(这是启动 Julia 时始终加载的默认模块)。
-
此类型未导出到 Main 模块。因此,您只能通过在其名称前加上定义它的模块名称来访问它。
第 3.3 节解释了 Base 和 Main 模块以及名称导出是如何工作的。
在我们的理解中,因为我们现在正在迭代矩阵的第二维索引,我们需要提取它的单个列。这是通过使用 aq[:, j] 表达式完成的。冒号 (😃 表示我们选择 aq 的第 j 列的所有行。
矩阵索引:实用指南
如果你使用矩阵,使用两个索引(行和列)来访问其元素,就像前面的例子一样。同样,当索引向量时,使用单个索引。一般来说,Julia 允许其他索引风格,这在编写高级泛型代码时很有用,但我建议你坚持“数组维度与索引数量一样多”的基本规则,这样会使你的代码更易于阅读和调试。
关于 aq[:, j]表达式的最后一点是,它会复制我们矩阵的第 j 列。有时,出于性能考虑,你可能更愿意不复制数据,而是使用 aq 矩阵的视图。这可以通过使用视图函数或@view 宏来实现,如下所示:
julia> [mean(view(aq, :, j)) for j in axes(aq, 2)]
8-element Vector{Float64}:
9.0
7.500909090909093
9.0
7.500909090909091
9.0
7.500000000000001
9.0
7.50090909090909
julia> [std(@view aq[:, j]) for j in axes(aq, 2)]
8-element Vector{Float64}:
3.3166247903554
2.031568135925815
3.3166247903554
2.0316567355016177
3.3166247903554
2.030423601123667
3.3166247903554
2.0305785113876023
在第一个例子中,当计算平均值时,我们使用视图函数。在这种情况下,我们将索引作为连续的参数传递给它。当使用@view 宏时,我们可以使用标准的索引语法。我在计算标准差时展示了这种方法。除了语法差异之外,编写 view(aq, :, j)和@view aq[:, j]是等效的。
什么是视图?
在 Julia 中,如果你有一个数组并创建其视图,则不会从父数组复制任何数据。相反,创建了一个轻量级对象,它延迟引用父数组。因此,父数组和它的视图共享相同的内存来存储数据。如果你修改视图中的数据,这种更改也会在父数组中可见。
在@view 宏的上下文中,让我提醒你一下 Julia 中宏工作方式的一个重要方面(我们在第三章讨论过)。如果你不带括号调用宏,它会急切地考虑所有跟随的内容作为一个表达式;尽可能多的代码被视为一个表达式。
这里有一个例子,说明这会导致问题。假设你想要创建一个包含向量两个视图的元组,并尝试以下代码:
julia> x = [1, 2, 3, 4]
4-element Vector{Int64}:
1
2
3
4
julia> (@view x[1:2], @view x[3:4])
ERROR: LoadError: ArgumentError: Invalid use of @view macro:
argument must be a reference expression A[...].
错误的原因是什么?问题是代码中的 x[1:2], @view[3:4]部分是一个单独的表达式,传递给了第一个@view 调用。为了解决这个问题,你需要使用第二种宏调用风格,它使用括号(就像调用函数一样):
julia> (@view(x[1:2]), @view(x[3:4]))
([1, 2], [3, 4])
4.1.4 复制与创建视图的性能考虑
你可能会问复制对操作性能的影响有多大。本节将介绍如何比较复制和创建视图的性能。为此,我们需要一个比 aq 矩阵大得多的数据集,因为对于实际相关的基准测试来说,它太小了。
这里是一个在 1000 万行和 10 列的矩阵上的基准测试示例:
julia> using BenchmarkTools
julia> x = ones(10⁷, 10)
10000000×10 Matrix{Float64}:
1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0
1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0
1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0
1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0
⋮ ⋮
1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0
1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0
1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0
1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0
julia> @btime [mean(@view $x[:, j]) for j in axes($x, 2)]; ❶
39.193 ms (1 allocation: 144 bytes)
julia> @btime [mean($x[:, j]) for j in axes($x, 2)];
201.935 ms (21 allocations: 762.94 MiB)
julia> @btime mean($x, dims=1);
38.959 ms (7 allocations: 688 bytes)
❶ 回想第三章,我们用$x 在代码中写出来以获得适当的基准结果,因为 x 是一个全局变量。
我们首先使用 ones 函数创建一个填充 1 的大矩阵。从基准测试中可以看出,使用视图(views)使用更少的内存并且更快。在基准测试中,我们还包括了 mean(x, dims=1)的调用,它也会产生预期的结果。这个函数是标准 Julia 分布的一部分,并且针对性能进行了优化。基准测试显示我们的代码大致上效率相当。
4.1.5 计算变量之间的相关性
让我们将你所学到的知识应用到计算研究变量之间的相关性上,因为这同样是我们想要计算的统计量。与计算均值和标准差的不同之处在于,在计算相关性时,我们需要同时将两个列传递给一个函数。在本节中,你将学习如何做到这一点。
我们想要计算第 1 列和第 2 列、第 3 列和第 4 列、第 5 列和第 6 列、以及第 7 列和第 8 列之间的相关性。这里是一个使用 Statistics 模块中的 cor 函数的简单方法:
[cor(aq[:, i], aq[:, i+1]) for i in 1:2:7]
4-element Vector{Float64}:
0.8164205163448398
0.8162365060002429
0.8162867394895983
0.8165214368885028
这次,cor 函数传递了两个向量(分别与 x 和 y 变量相关)。正如你所见,相关性相似。让我来解释一下 1:2:7 的表达式。你已经学习了关于 start:stop 形式的范围,它从 start 开始,到 stop 结束,包含 start 和 stop,步长为 1。start:step:stop 这种风格是这种语法的推广,允许你通过参数 step 指定范围的步长;参见图 4.4。

图 4.4 解释具有自定义步长的范围语法。在这个例子中,步长值为 2,所以我们正在迭代奇数:1, 3, 5, 7。
让我们通过使用你在第三章中学到的 collect 函数来检查 step 参数是否按我解释的那样工作:
julia> collect(1:2:7)
4-element Vector{Int64}:
1
3
5
7
在[cor(aq[:, i], aq[:, i+1]) for i in 1:2:7]表达式中,我故意使用了复制操作。我鼓励你通过使用视图作为练习来重写这段代码。
练习 4.1 使用视图(无论是 view 函数还是@view 宏)重写[cor(aq[:, i], aq[:, i+1]) for i in 1:2:7]表达式。通过使用 BenchmarkTools.jl 包中的@benchmark 宏来比较两种方法的性能。
4.1.6 拟合线性回归
我们现在可以转向使用普通最小二乘法(OLS)来拟合线性回归。稍后你将学习使用 GLM.jl 包估计此类模型参数的更通用和方便的 API,但到目前为止,我们只限于基本方法来学习如何在 Julia 中处理矩阵。我们想要拟合的线性回归形式为y = a + b * x + error,其中a和b是必须估计的未知系数。我们将选择它们,使得所有观测值中误差项的平方和最小化。
如同你可能从入门统计学课程中学到的,为了估计线性回归的参数,我们需要一个包含目标变量的向量 y 和一个模型特征矩阵。至关重要的是,因为我们想要学习两个参数 a 和 b,所以我们的模型中有两个特征。与 a 参数相关的特征被称为常数项,必须表示为一个只包含 1 的列。第二个特征应该是我们的x变量。让我们从我们的 aq 数据集中提取第一组x和y变量(索引 1 和 2)来构建目标向量 y 和特征矩阵 X:
julia> y = aq[:, 2]
11-element Vector{Float64}:
8.04
6.95
7.58
8.81
8.33
9.96
7.24
4.26
10.84
4.82
5.68
julia> X = [ones(11) aq[:, 1]] ❶
11×2 Matrix{Float64}:
1.0 10.0
1.0 8.0
1.0 13.0
1.0 9.0
1.0 11.0
1.0 14.0
1.0 6.0
1.0 4.0
1.0 12.0
1.0 7.0
1.0 5.0
❶ 我们使用 11 是因为我们知道我们的数据有 11 个观测值。
可能会让你惊讶的是[ones(11) aq[:, 1]]语法。然而,你已经学习了我们在这里使用的一切构建块。ones 函数生成一个包含 11 个 1 的向量。然后我们使用构造矩阵的方法,通过在列之间用空格分隔并将它们括在方括号中来合并列。这是我们在这个部分开始时使用的方法。唯一的区别是现在我们是在水平连接整个向量,而不是矩阵的单个单元格。
这个操作按预期工作,因为在 Julia 中向量总是被视为列向量。当你用[1, 2, 3]这样的字面量定义一个向量,使用视觉上水平的语法(为了在代码中节省垂直空间),产生的对象是一个列向量。这一点通过 Julia 在 REPL 中垂直打印向量的事实得到了强调,而 R 则是水平打印。
我们现在准备估计我们模型的参数。你可以使用反斜杠(\)操作符:
julia> X \ y
2-element Vector{Float64}:
3.000090909090909
0.500090909090909
在这种情况下,常数项被近似估计为 3.0,与x变量的系数为 0.5。
反斜杠操作符
当 A 是矩阵时,A \ B 操作的结果取决于其形状。
如果 A 是方阵,结果 X 满足 A * X = B。
如果 A 不是方阵,结果 X 是表达式 norm(A * X - B)的最小化器,其中 norm 是一个计算欧几里得范数的函数;它在 LinearAlgebra 模块中定义。
在线性回归的背景下,当 A 是特征矩阵而 B 是目标变量向量时,A \ B 会产生最小二乘估计或回归参数。
我们现在准备估计所有四个模型:
julia> [[ones(11) aq[:, i]] \ aq[:, i+1] for i in 1:2:7]
4-element Vector{Vector{Float64}}:
[3.000090909090909, 0.500090909090909]
[3.0009090909090905, 0.5]
[3.0024545454545457, 0.4997272727272727]
[3.001727272727273, 0.4999090909090908]
再次强调,所有模型几乎都是相同的。注意这次,我们创建了一个包含向量的向量。在 Julia 中,数组可以存储任何类型的对象,包括其他数组(通常称为嵌套数组)。如果你预计会大量使用这些数据结构,你可能会考虑学习 ArraysOfArrays.jl 包提供的功能。
浮点数计算的精度
浮点运算使用有限精度进行。一个后果是,在不同的硬件上运行相同的 Julia 代码或使用执行线性代数运算的不同库的实现可能会产生略微不同的结果。(Julia 允许切换线性代数库;见github.com/JuliaLinearAlgebra/MKL.jl。)
你可能会在本书的示例中看到这种效果。例如,我们在这个部分使用的 X \ y 表达式可能会产生与我之前展示不同的输出。差异可能出现在输出的最低有效位。
我们现在可以计算确定系数 R²:
julia> function R²(x, y)
X = [ones(11) x]
model = X \ y
prediction = X * model
error = y - prediction
SS_res = sum(v -> v ^ 2, error)
mean_y = mean(y)
SS_tot = sum(v -> (v - mean_y) ^ 2, y)
return 1 - SS_res / SS_tot
end
R² (generic function with 1 method)
julia> [R²(aq[:, i], aq[:, i+1]) for i in 1:2:7]
4-element Vector{Float64}:
0.6665424595087751
0.6662420337274844
0.6663240410665592
0.6667072568984652
首先,我们定义一个 R²函数,它接受 x 特征和 y 目标。记住,如果你不记得如何输入 2,你可以通过按?并粘贴 2 来轻松获取帮助:
help?> ²
"²" can be typed by \²<tab>
在 R²函数中,我们首先重现了我们刚才讨论的模型参数估计步骤。然后,我们使用 X * 模型表达式来预测,利用了 Julia 中的乘法运算符默认执行矩阵乘法的事实。接下来,我们将预测误差存储在 error 变量中。确定系数定义为 1 减去模型平方误差之和与目标变量与其均值偏差之和的比。我们在函数的最后部分计算这些量。在 R²函数的主体中,我们使用了 mean 函数,这要求你首先加载 Statistics 模块以确保计算无错误执行。
如你所见,将 R²函数应用于我们的数据几乎为所有四个数据集产生了相同的结果。这是我们想要进行的最后分析。
4.1.7 绘制 Anscombe 的四重奏数据
现在你已经准备好学习为什么 Anscombe 的四重奏数据如此著名了。让我们绘制数据以发现所有四个数据集的分布都截然不同。我们将使用 Plots.jl 包来进行绘图。首先,我们在散点图上绘制第一个数据集以热身:
julia> using Plots
julia> scatter(aq[:, 1], aq[:, 2]; legend=false)
图 4.5 展示了你应该看到的输出。

图 4.5 Anscombe 的四重奏的第一个数据集的图表。点分布散乱,但似乎大致遵循线性趋势。
如果你运行示例代码,你会看到第一次生成图表的时间是明显的。正如 1.4 节中解释的那样,这是预期的,因为 Julia 需要编译我们调用的函数。幸运的是,这是一个一次性成本,连续生成的图表会很快。
现在让我们可视化四个图表。使用 Plots.jl,这相当简单。你只需要用 plot 函数包裹四个 scatter 调用:
julia> plot(scatter(aq[:, 1], aq[:, 2]; legend=false),
scatter(aq[:, 3], aq[:, 4]; legend=false),
scatter(aq[:, 5], aq[:, 6]; legend=false),
scatter(aq[:, 7], aq[:, 8]; legend=false))
这将生成图 4.6 中所示的图表。正如你所见,四个数据集看起来完全不同。

图 4.6 Anscombe 四重奏中的四个数据集的图表。尽管,正如我们所检查的,所有四个数据集都具有相同的基本统计摘要,但 x 和 y 变量之间的关系在每个数据集中都是完全不同的。
在我们完成本节之前,让我向大家展示另一种制作最后图表的方法。你可能认为有很多不必要的输入。确实如此。以下是使用列表推导式达到相同结果的代码:
julia> plot([scatter(aq[:, i], aq[:, i+1]; legend=false)
for i in 1:2:7]...)
然而,请注意,我们必须添加一个小的细节。由于绘图函数接受子图作为其连续的位置参数,我们不能直接将我们的列表推导式产生的向量传递给它,因为这会产生错误。我们需要在函数调用中将向量展开为多个位置参数。这种操作称为展开,它使用三个点(...)执行。在我们的代码中,它们直接位于函数调用中的列表推导式之后。
如果你对手数组操作的更多细节感兴趣,请查看 Julia 手册中关于多维数组的部分(docs.julialang.org/en/v1/manual/arrays/)。你还可以查看mng.bz/neYe以了解我们描述的问题如何使用 DataFrames.jl(我们将在第二部分中使用此包)来解决。
到目前为止,你应该已经掌握了如何创建向量数组和数组,如何操作它们,以及如何将它们传递给统计和绘图函数。数组是一种特殊类型的集合,其中每个维度都可以用连续的整数范围进行索引。然而,有时你需要一种允许你使用任何值进行索引的数据结构。这是可能的,使用字典,我们将在下一节中讨论。
4.2 使用字典映射键值对
在进行数据科学时,经常使用的一种标准集合类型是字典。我们将通过解决著名的 Sicherman 骰子谜题来介绍字典。在本节中,你将学习如何创建字典,向字典中添加键,从中检索值,以及比较字典。
Sicherman 谜题
一个标准的骰子有六个面,面上的数字从 1 到 6。在许多游戏中,玩家掷两个标准的骰子,并将得到的结果相加。在 Sicherman 谜题中,我们被要求检查是否有可能以与标准骰子完全不同的方式给一对立方体的面编号,以便这些立方体可以用于任何骰子游戏,并且所有概率都将与使用标准骰子时相同。更正式地说,我们想要检查是否存在其他成对的两个六面骰子,这些骰子的面用正整数编号,并且它们掷出的值的总和的概率分布与标准骰子相同。
为了解决这个谜题,我们将使用字典。字典是键到值的映射。在我们的例子中,由于我们考虑两个六面的骰子,我们可以有 36 种不同的投掷结果(每个骰子有六个面,6 × 6 = 36 种可能的结果组合)。因此,我们的映射将告诉我们,对于投掷值的总和的每个值,它在总共 36 次中出现了多少次。
创建一个字典
让我们首先为标准骰子的一个对创建这个分布的字典:
julia> two_standard = Dict{Int, Int}()
Dict{Int64, Int64}()
julia> for i in [1, 2, 3, 4, 5, 6]
for j in [1, 2, 3, 4, 5, 6]
s = i + j
if haskey(two_standard, s)
two_standard[s] += 1
else
two_standard[s] = 1
end
end
end
julia> two_standard
Dict{Int64, Int64} with 11 entries:
5 => 4
12 => 1
8 => 5
6 => 5
11 => 2
9 => 4
3 => 2
7 => 6
4 => 3
2 => 1
10 => 3
在我们的代码中,我们首先创建一个空的 two_standard 字典。请注意,通过写入 Dict{Int, Int},我们为我们的类型指定了两个参数。第一个参数是允许的键的类型,第二个参数是允许的值的类型。
接下来,使用双层循环,我们遍历投掷的可能结果的 36 种组合,并将它们相加,将总和存储在 s 变量中。在循环内部,使用 haskey 函数,我们检查字典是否已经包含 s 键的映射。如果包含,我们增加字典条目的计数。否则,这是我们第一次遇到给定的键,我们将其赋值为 1。请注意,字典的索引使用方括号,就像数组或元组的索引一样。
我们可以轻松地提取字典的键和值的列表:
julia> keys(two_standard)
KeySet for a Dict{Int64, Int64} with 11 entries. Keys:
5
12
8
6
11
9
3
7
4
2
10
julia> values(two_standard)
ValueIterator for a Dict{Int64, Int64} with 11 entries. Values:
4
1
5
5
2
4
2
6
3
1
3
你可以看到,这两个值的类型看起来都不像是向量。它们只是字典内容的视图。因此,如果我们想绘制投掷总和的分布图,我们需要收集它们,这样就会将视图转换为向量。以下是应该这样做的方法:
julia> using Plots
julia> scatter(collect(keys(two_standard)),
collect(values(two_standard));
legend=false, xaxis=2:12)
注意,通过使用 xaxis 关键字参数,我们明确设置了图中的 x 轴标签。图 4.7 显示了你应该获得的图表。

图 4.7 投掷两个标准骰子的总和分布。可能的结果范围从 2 到 12。分布是对称的。
在前面的例子中,我想向你展示 keys 和 values 函数的使用。然而,Plots.jl 允许你更轻松地可视化字典的内容。你可以写 scatter(two_standard; legend=false, xaxis=2:12)来获得相同的图表。
解决 Sicherman 谜题
现在我们已经准备好开始解决这个谜题了。为了减少搜索空间,请注意,由于等于 2 的最小总和只出现一次,两个骰子上必须恰好有一个 1。同样,请注意,最大的总和等于 12,所以任何骰子上的最大数字是 11。因此,我们专注于骰子的五个面(正如讨论的那样,只有一个面必须包含 1)的值,范围从 2 到 11。让我们生成一个包含所有这些骰子的向量。请注意,我们只能关注骰子上非递减的数字序列:
julia> all_dice = [[1, x2, x3, x4, x5, x6]
for x2 in 2:11
for x3 in x2:11
for x4 in x3:11
for x5 in x4:11
for x6 in x5:11]
2002-element Vector{Vector{Int64}}:
[1, 2, 2, 2, 2, 2]
[1, 2, 2, 2, 2, 3]
[1, 2, 2, 2, 2, 4]
[1, 2, 2, 2, 2, 5]
⋮
[1, 10, 10, 10, 11, 11]
[1, 10, 10, 11, 11, 11]
[1, 10, 11, 11, 11, 11]
[1, 11, 11, 11, 11, 11]
注意,在这段代码中,我们使用了 Julia 中尚未讨论的语法的一个很好的特性。for 循环可以嵌套,并且跟随前一个循环的循环可以使用外部循环中定义的变量。这样的嵌套理解就像我们编写了多个 for 循环,一个嵌套在另一个内部。
现在我们看到我们有 2,002 个这样的骰子。我们准备测试所有骰子对,以检查哪些具有与我们的两个 _ 标准分布相同的分布。以下是操作方法:
julia> for d1 in all_dice, d2 in all_dice
test = Dict{Int, Int}()
for i in d1, j in d2
s = i + j
if haskey(test, s)
test[s] += 1
else
test[s] = 1
end
end
if test == two_standard
println(d1, " ", d2)
end
end
[1, 2, 2, 3, 3, 4] [1, 3, 4, 5, 6, 8]
[1, 2, 3, 4, 5, 6] [1, 2, 3, 4, 5, 6]
[1, 3, 4, 5, 6, 8] [1, 2, 2, 3, 3, 4]
在这段代码中,我们以与之前创建两个 _ 标准字典相同的方式创建测试字典。唯一的区别是我们使用了一种不同的、更简洁的 for 循环写法。例如,在 for i in d1, j in d2 中,我们立即创建一个嵌套循环,遍历 d1 和 d2 值的笛卡尔积。
还要注意,我们可以使用==比较来检查两个字典是否具有相同的键到值的映射。
我们编写的代码输出了三对骰子。一对是标准的骰子,正如预期的那样。另外两对相同,只是顺序相反。因此,我们已经了解到 Sicherman 骰子谜题有一个独特的解决方案。有些令人惊讶的是,我们现在知道,如果我们拿一个有数字 1、2、2、3、3、4 的骰子和另一个有数字 1、3、4、5、6、8 的骰子,那么它们投掷值的总和的分布与两个标准骰子的分布是无法区分的。
示例代码有一个缺点。它违反了“不要重复自己”(DRY)原则。我们两次重复了相同的代码来填充 Dict{Int, Int}字典:第一次用于填充 two_standard 字典,第二次用于反复填充测试字典。在这种情况下,我通常将重复的代码封装在函数中。这项操作是作为练习留给你的,以加强你在 Julia 中学习函数定义语法的知识。
练习 4.2:重写解决 Sicherman 谜题的代码,将处理逻辑封装在函数中。创建一个名为 dice_distribution 的函数,该函数接受两个骰子的值作为参数,并生成一个包含这些值可能组合的总和分布的字典。接下来,编写另一个名为 test_dice 的函数,在该函数中创建 all_dice 变量,然后创建 two_standard 变量,并最终运行主循环,比较 all_dice 向量中所有骰子的分布与 two_standard 分布。
Julia 中的标准数据集合
在本节中,我们提供了一个使用字典的简短示例。如果你想了解更多关于 Base Julia 支持的其他集合类型,请参阅 Julia 手册(docs.julialang.org/en/v1/base/collections/)。此外,DataStructures.jl 包提供了更多你可能需要的集合类型。以下是在实践中最需要的几个集合类型。
与字典相关的一种数据结构,有时在数据科学工作流程中使用,是 集合。Set 类型在 Base Julia 中可用。与字典不同,集合只保留一组唯一的值。集合允许你执行的基本操作包括添加值、移除值和快速检查某个值是否存在于集合中。
在 Base Julia 中,Dict 和 Set 类型存储它们的元素顺序未定义。在 DataStructures.jl 包中提供的保留插入顺序的类型分别是 OrderedDict 字典和 OrderedSet 集合。
如果你对于使用 DataFrames.jl 解决 Sicherman 掷骰子谜题的替代方案感兴趣,请查看我的博客文章“使用 DataFrames.jl 解决 Sicherman 掷骰子谜题”(mng.bz/p692)。
你现在已经知道了如何使用字典,与数组不同,字典允许你使用任何值作为索引。一个自然的问题是我们是否可以有一个数据结构,它既能支持整数索引,又能通过名称选择值。确实,Julia 提供了这样的集合。它被称为 NamedTuple,我们将在下一节讨论它。
4.3 通过使用命名元组来结构化你的数据
当我们在 4.1 节分析 Anscombe 的四重奏数据时,你可能会有一种感觉,那就是我们在数据中缺少一些结构。NamedTuple 类型是向你的代码添加结构的基本方法。你可以把 NamedTuple 视为给元组的连续元素添加名称的方式。
在本节中,你将学习如何创建 NamedTuple 并访问其元素。你还将看到如何使用 GLM.jl 包拟合线性模型。
我们将使用 NamedTuple 类型重写 Anscombe 的四重奏数据示例。从我们已经在列表 4.1 中定义的 aq 矩阵开始(这里重复列出以方便你使用):
julia> aq = [10.0 8.04 10.0 9.14 10.0 7.46 8.0 6.58
8.0 6.95 8.0 8.14 8.0 6.77 8.0 5.76
13.0 7.58 13.0 8.74 13.0 12.74 8.0 7.71
9.0 8.81 9.0 8.77 9.0 7.11 8.0 8.84
11.0 8.33 11.0 9.26 11.0 7.81 8.0 8.47
14.0 9.96 14.0 8.1 14.0 8.84 8.0 7.04
6.0 7.24 6.0 6.13 6.0 6.08 8.0 5.25
4.0 4.26 4.0 3.1 4.0 5.39 19.0 12.50
12.0 10.84 12.0 9.13 12.0 8.15 8.0 5.56
7.0 4.82 7.0 7.26 7.0 6.42 8.0 7.91
5.0 5.68 5.0 4.74 5.0 5.73 8.0 6.89]
11×8 Matrix{Float64}:
10.0 8.04 10.0 9.14 10.0 7.46 8.0 6.58
8.0 6.95 8.0 8.14 8.0 6.77 8.0 5.76
13.0 7.58 13.0 8.74 13.0 12.74 8.0 7.71
9.0 8.81 9.0 8.77 9.0 7.11 8.0 8.84
11.0 8.33 11.0 9.26 11.0 7.81 8.0 8.47
14.0 9.96 14.0 8.1 14.0 8.84 8.0 7.04
6.0 7.24 6.0 6.13 6.0 6.08 8.0 5.25
4.0 4.26 4.0 3.1 4.0 5.39 19.0 12.5
12.0 10.84 12.0 9.13 12.0 8.15 8.0 5.56
7.0 4.82 7.0 7.26 7.0 6.42 8.0 7.91
5.0 5.68 5.0 4.74 5.0 5.73 8.0 6.89
4.3.1 定义命名元组和访问其内容
首先,作为一个练习,为第一个数据集创建一个命名元组:
julia> dataset1 = (x=aq[:, 1], y=aq[:, 2])
(x = [10.0, 8.0, 13.0, 9.0, 11.0, 14.0, 6.0, 4.0, 12.0, 7.0, 5.0],
y = [8.04, 6.95, 7.58, 8.81, 8.33, 9.96, 7.24, 4.26, 10.84, 4.82, 5.68])
如你所见,创建一个 NamedTuple 很简单。它与 Tuple 类似,只是我们给元素命名了。NamedTuple,就像 Tuple 一样,是不可变的。你可以通过数字索引它,但也可以通过点(.)和字段名来访问其字段:
julia> dataset1[1]
11-element Vector{Float64}:
10.0
8.0
13.0
9.0
11.0
14.0
6.0
4.0
12.0
7.0
5.0
julia> dataset1.x
11-element Vector{Float64}:
10.0
8.0
13.0
9.0
11.0
14.0
6.0
4.0
12.0
7.0
5.0
现在我们将在下一个列表中创建一个嵌套的命名元组,包含我们的四个数据集。
列表 4.2 定义一个存储 Anscombe 的四重奏数据的命名元组
julia> data = (set1=(x=aq[:, 1], y=aq[:, 2]),
set2=(x=aq[:, 3], y=aq[:, 4]),
set3=(x=aq[:, 5], y=aq[:, 6]),
set4=(x=aq[:, 7], y=aq[:, 8]))
(set1 = (x = [10.0, 8.0, 13.0, 9.0, 11.0, 14.0, 6.0, 4.0, 12.0, 7.0, 5.0],
y = [8.04, 6.95, 7.58, 8.81, 8.33, 9.96, 7.24, 4.26, 10.84, 4.82, 5.68]),
set2 = (x = [10.0, 8.0, 13.0, 9.0, 11.0, 14.0, 6.0, 4.0, 12.0, 7.0, 5.0],
y = [9.14, 8.14, 8.74, 8.77, 9.26, 8.1, 6.13, 3.1, 9.13, 7.26, 4.74]),
set3 = (x = [10.0, 8.0, 13.0, 9.0, 11.0, 14.0, 6.0, 4.0, 12.0, 7.0, 5.0],
y = [7.46, 6.77, 12.74, 7.11, 7.81, 8.84, 6.08, 5.39, 8.15, 6.42, 5.73]),
set4 = (x = [8.0, 8.0, 8.0, 8.0, 8.0, 8.0, 8.0, 19.0, 8.0, 8.0, 8.0],
y = [6.58, 5.76, 7.71, 8.84, 8.47, 7.04, 5.25, 12.5, 5.56, 7.91, 6.89]))
现在,你可以像这样从我们的集合中获取单个数据集:
julia> data.set1
(x = [10.0, 8.0, 13.0, 9.0, 11.0, 14.0, 6.0, 4.0, 12.0, 7.0, 5.0],
y = [8.04, 6.95, 7.58, 8.81, 8.33, 9.96, 7.24, 4.26, 10.84, 4.82, 5.68])
julia> data.set1.x
11-element Vector{Float64}:
10.0
8.0
13.0
9.0
11.0
14.0
6.0
4.0
12.0
7.0
5.0
4.3.2 分析存储在命名元组中的 Anscombe 四重奏数据
我们现在可以使用数据变量重制第 4.1 节中分析的选定步骤。首先,计算每个集合中 x 变量的平均值:
julia> using Statistics
julia> map(s -> mean(s.x), data)
(set1 = 9.0, set2 = 9.0, set3 = 9.0, set4 = 9.0)
在代码中,我们创建了一个匿名函数 s -> mean(s.x),它从传递的 NamedTuple 中提取 x 字段并计算其平均值。值得注意的是,map 函数足够智能,可以返回一个 NamedTuple,它保留了源 NamedTuple 中处理过的字段的名称。计算皮尔逊相关系数的工作方式类似:
julia> map(s -> cor(s.x, s.y), data)
(set1 = 0.8164205163448398, set2 = 0.8162365060002429,
set3 = 0.8162867394895983, set4 = 0.8165214368885028)
最后,让我们使用 GLM.jl 包为第一个数据集拟合一个线性模型。在模型中,目标变量是 y,我们有一个特征 x:
julia> using GLM
julia> model = lm(@formula(y ~ x), data.set1)
StatsModels.TableRegressionModel{LinearModel{GLM.LmResp{Vector{Float64}},
GLM.DensePredChol{Float64, LinearAlgebra.CholeskyPivoted{Float64,
Matrix{Float64}}}}, Matrix{Float64}} ❶
y ~ 1 + x ❷
Coefficients:
───────────────────────────────────────────────────────────────────────
Coef. Std. Error t Pr(>|t|) Lower 95% Upper 95%
───────────────────────────────────────────────────────────────────────
(Intercept) 3.00009 1.12475 2.67 0.0257 0.455737 5.54444
x 0.500091 0.117906 4.24 0.0022 0.23337 0.766812
───────────────────────────────────────────────────────────────────────
❶ 输出的一部分表示模型变量的类型。您可以安全地忽略它。
❷ 这表示拟合模型所使用的公式。
观察以下代码中的几个特点:
-
我们使用 @formula(y ~ x) 语法来说明我们的 NamedTuple 中的 x 字段是一个特征,而 y 是目标变量。在 @formula 宏内部,我们传递目标变量名称,然后是波浪号 (~) 和特征变量名称。在第二部分,我们将更详细地讨论此类公式的构建方式。如果您想了解 @formula 领域特定语言的全部细节,可以在 StatsModels.jl 包的文档中找到它们(
mng.bz/O6Qo)。 -
lm 函数的第一个位置参数是模型公式,第二个位置参数是由我们的 data.set1 命名元组表示的表。lm 函数从传递的表中获取数据,并拟合由传递的公式指定的线性回归模型。lm 函数返回的对象存储了我们模型估计参数的信息。当在 REPL 中打印此对象时,它给我们一个包含模型总结统计信息的表,其中我们的模型中的 x 变量和截距自动获得适当的名称。
如您所见,我们线性模型得到的估计值与第 4.1 节中得到的结果相同。
作为最后一个例子,让我们使用 GLM.jl 中的 r2 函数计算我们模型的确定系数:
julia> r2(model)
0.666542459508775
再次,结果与之前的一致。使用 GLM.jl 包构建模型比我们在第 4.1 节中手动构建模型要强大得多。现在您可以使用存储在 NamedTuple 中的数据重制图 4.6(在一个图中显示所有四个数据集的散点图)。
练习 4.3 使用 4.2 列表中定义的命名元组数据重制图 4.6。
4.3.3 理解 Julia 中的复合类型和值的可变性
在结束本节之前,深入讨论两个重要概念是值得的:复合结构和值的可变性。我在这里讨论这些主题是为了帮助您更好地理解数组、字典、命名元组以及本章讨论的其他类型之间的差异。
复合类型
在 4.3.2 节中我们使用的模型变量是一个TableRegressionModel类型的复合类型——即一个结构体。在进行基本操作时,你很可能不需要自己创建它们,但你经常会遇到它们作为函数从包中返回的结果。
TableRegressionModel类型在StatsModels.jl包中定义如下:
struct TableRegressionModel{M,T} <: RegressionModel
model::M
mf::ModelFrame
mm::ModelMatrix{T}
end
在基本层面上,你不需要理解这个定义的所有细节。对我们来说现在重要的是,这个结构体定义了三个字段:model、mf 和 mm。当你得到具有这种类型的值时,你可以通过使用点(.)轻松访问其字段,就像在命名元组(在第二部分,你将了解到点操作符的工作方式要复杂一些,但默认情况下它以我描述的方式行为)中一样:
julia> model.mm
ModelMatrix{Matrix{Float64}}([1.0 10.0; 1.0 8.0; ... ; 1.0 7.0; 1.0 5.0],
[1, 2])
因此,你可以将这些对象视为与命名元组类似,区别在于它们的类型有一个特定的名称(在我们的例子中是TableRegressionModel),并且不能像命名元组那样用数字索引。你可以在 Julia 手册中了解更多关于定义复合类型的信息(mng.bz/YKwK)。
值的可变性
Julia 区分可变和不可变类型。以下是本书中遇到的一些类型分类:
-
不可变——Int、Float64、String、Tuple、NamedTuple、结构体
-
可变——数组(因此也包括向量和矩阵)、字典以及使用
mutable关键字创建的结构体
你可能会问不可变和可变类型有什么区别。关键是可变值可以被改变。这听起来可能很显然,但关键的是它们也可以被传递给它们的函数所改变。这样的副作用可能会相当令人惊讶。因此,正如第二章所讨论的,对那些会改变其参数的函数使用感叹号后缀(!)进行注释至关重要。同样,正如第二章所讨论的,记住在函数末尾添加!只是一种约定(以!结尾的函数不会得到 Julia 编译器的特殊处理;这种约定只是为了让用户更直观地看到函数可能会改变其参数)。以下是两个数据突变的工作示例。
在第一个例子中,我们看到在向量上调用unique和unique!函数之间的区别。它们都从集合中移除重复项。它们之间的区别在于unique返回一个新的向量,而unique!则就地工作:
julia> x = [3, 1, 3, 2]
4-element Vector{Int64}:
3
1
3
2
julia> unique(x) ❶
3-element Vector{Int64}:
3
1
2
julia> x ❷
4-element Vector{Int64}:
3
1
3
2
julia> unique!(x) ❸
3-element Vector{Int64}:
3
1
2
julia> x ❹
3-element Vector{Int64}:
3
1
2
❶ unique函数返回一个新的向量。它不会改变传入的向量。
❷ x 向量未改变。
❸ 独特的unique函数会就地改变 x 向量。
❹ x 向量被改变。
unique函数不会改变传入的参数,而是分配一个新的向量并去重。另一方面,unique!函数会就地更新传入的向量。
第二个示例旨在向您展示,即使您的数据结构是不可变的,它也可能包含可变元素,这些元素可以被函数修改。在这个例子中,我们使用 empty! 函数,它接受一个可变集合作为参数,并就地移除存储在其内的所有元素:
julia> empty_field!(nt, i) = empty!(nt[i]) ❶
empty_field! (generic function with 1 method)
julia> nt = (dict = Dict("a" => 1, "b" => 2), int=10) ❷
(dict = Dict("b" => 2, "a" => 1), int = 10)
julia> empty_field!(nt, 1)
Dict{String, Int64}()
julia> nt ❸
(dict = Dict{String, Int64}(), int = 10)
❶ empty_field! 函数在 nt 对象的第 i 个元素上调用 empty! 函数。
❷ nt 命名元组的第一个元素是一个字典;字典是可变的。
❸ 执行 empty_field! 函数后,存储在 nt 命名元组中的字典为空。
在这个例子中,我们创建了 empty_field! 函数,它接受一个对象并尝试在位置 i 上对其进行索引,然后使用 empty! 函数就地清空存储的值。接下来,我们创建一个具有两个字段的命名元组:一个字典和一个整数。Dict("a" => 1, "b" => 2) 语法是一种方便的方法来初始化字典,其中每个元素 "a" => 1 是一个 Pair 对象,将单个键映射到单个值。
需要观察的关键点是,当我们调用 empty_field!(nt, 1) 时,存储在 nt 变量中的字典被清空,尽管 nt 是一个不可变的命名元组。然而,它包含一个可变对象作为其字段。
总结一下,强调我们已经讨论过的内容,Julia 在向函数传递参数时不会复制数据。如果一个对象被传递给函数,并且它包含一个结构(即使是嵌套的)是可变的,那么该对象可能被函数修改。如果你想创建一个在传递给函数时完全独立的对象,以确保原始值不会被修改,请使用 deepcopy 函数来创建它。
摘要
-
数组是 Julia 中最常见的容器,因为大多数机器学习算法都使用数组作为输入。在 Julia 中,数组是语言的核心部分,因此它们既高效又易于使用。
-
您可以使用 Statistics 模块和 GLM.jl 包轻松地对您的数据进行分析,包括确定均值、标准差、相关性和线性模型的估计。所有提供这些功能的函数都接受数组作为输入。
-
与 R、Fortran 和 MATLAB 一样,Julia 默认使用基于 1 的索引数组。
-
在 Julia 中,向量始终被认为是列向量。
-
您可以使用字典在 Julia 中存储键值映射。重要的是要记住,在 Julia 中,字典中的键可以是任何类型的值。
-
数组和字典是可变容器,这意味着您可以更改其内容。
-
元组和命名元组类似于一维数组,但它们是不可变容器。一旦创建,就不允许更改其内容。
-
命名元组与元组不同,除了有索引外,它们的所有元素还具有名称,您可以使用这些名称来访问它们。
5 集合处理的高级主题
本章涵盖了
-
向量化你的代码,即广播
-
理解参数化类型的子类型规则
-
将 Julia 与 Python 集成
-
执行 t-SNE 维度缩减
你已经从第二章中了解到如何使用循环、map 函数和列表推导来处理向量。本章介绍了实践中常用的一种方法:广播。
5.2 节解释了与参数化类型的子类型规则相关的一个更高级的话题,这通常会引起学习 Julia 的人提出问题。这个问题与集合密切相关,因为,正如你将在本章中学到的,最常见的集合类型,如数组或字典,都是参数化的。因此,如果你想了解如何正确编写允许集合作为其参数的方法签名,你需要学习这个话题。
5.3 节专注于将 Julia 与 Python 集成。你将了解到在 Julia 格式和 Python 格式之间转换集合是由 PyCall.jl 包自动完成的。因此,你可以轻松地在你的 Julia 项目中使用现有的执行数据集合操作的 Python 代码。作为一个这样的集成示例,我将向你展示如何使用 Python 的 scikit-learn 库执行数据 t-SNE 维度缩减(lvdmaaten.github.io/tsne/)。运行 PyCall.jl 示例需要在你的计算机上有一个正确配置的 Python 安装。因此,请确保遵循附录 A 中的环境设置说明。
5.1 使用广播向量化你的代码
在我们第 2、3 和 4 章中讨论的示例中,我们使用了三种执行重复操作的方法:
-
遍历集合的 for 循环
-
将函数应用于集合的 map 函数
-
列表推导
这三种语法功能强大且灵活;然而,许多为数据科学设计的语言提供了执行向量运算的方法,也称为广播操作。在 Julia 中,广播操作也得到支持。在本节中,你将学习如何使用它。
我们将通过回到 Anscombe 的四重奏数据来讨论广播的工作原理。然而,让我们先从一些玩具示例上的广播解释开始。
5.1.1 理解 Julia 中广播的语法和意义
Julia 语言的 重要设计规则是函数的定义遵循数学规则。你已经在第四章中看到了这个规则在起作用,该章展示了乘法运算符 * 使用矩阵乘法规则。因此,以下代码遵循矩阵乘法规则:
julia> x = [1 2 3]
1×3 Matrix{Int64}:
1 2 3
julia> y = [1, 2, 3]
3-element Vector{Int64}:
1
2
3
julia> x * y
1-element Vector{Int64}:
14
该操作的效果就像我们乘以 x,它绑定到一个 1 × 3 的矩阵,以及一个三个元素的向量 y,在 Julia 中向量始终被解释为列向量。
你可能会问,那么我们应该如何逐元素相乘两个向量,这在数学上被称为 Hadamard product。显然,仅使用 * 操作符是不可能的,因为它执行标准的矩阵乘法:
julia> a = [1, 2, 3]
3-element Vector{Int64}:
1
2
3
julia> b = [4, 5, 6]
3-element Vector{Int64}:
4
5
6
julia> a * b
ERROR: MethodError: no method matching *(::Vector{Int64}, ::Vector{Int64})
你会得到一个错误,因为向量与向量的乘法不是一个有效的数学运算。相反,我们需要进行广播。在 Julia 中,向操作符添加广播很容易。你只需在前面加上一个点(.),如下所示:
julia> a .* b
3-element Vector{Int64}:
4
10
18
当使用像 .* 这样的广播操作符时,Julia 会迭代传入的集合的元素(在我们的例子中,是向量 a 和 b),并在点(在我们的例子中,是 *)之后逐元素应用操作符。因此,在这种情况下,广播的结果与以下操作产生的结果相同:
julia> map(*, a, b)
3-element Vector{Int64}:
4
10
18
julia> [a[i] * b[i] for i in eachindex(a, b)]
3-element Vector{Int64}:
4
10
18
在这个 map 示例中,我们正在传递两个集合(而不是像之前解释 map 的工作原理时那样只传递一个)。传入的函数(在这种情况下是 *,)会迭代地逐元素应用到这些集合上,直到其中一个被耗尽。
在理解示例中,值得讨论 eachindex (a, b) 表达式,它会产生以下结果:
julia> eachindex(a, b)
Base.OneTo(3)
eachindex 函数产生可以用于索引传入的 a 和 b 参数的索引。在这种情况下,这些只是从 1 到 3 的整数。因此,你可以使用这些值索引 a 和 b 向量;例如,以下索引表达式是有效的:a[1],b[2],a[3],但 a[0] 或 b[4] 是无效的,因为它们不在 Base.OneTo(3) 指定的范围内。
如果 a 和 b 的大小不匹配,我们会得到一个错误:
julia> eachindex([1, 2, 3], [4, 5])
ERROR: DimensionMismatch("all inputs to eachindex must have the same
indices, got Base.OneTo(3) and Base.OneTo(2)")
这与 map 函数的一个重要区别,map 函数内部不使用 eachindex 函数,而是迭代集合,直到其中一个被耗尽,正如我之前解释的那样:
julia> map(*, [1, 2, 3], [4, 5])
2-element Vector{Int64}:
4
10
使用 map 的实际考虑
如果你向 map 函数传递多个集合,你应该事先检查它们是否有相同的长度。大多数情况下,使用与 map 函数长度不等的集合是一个错误。
广播,就像 eachindex 函数一样,会检查传入的对象的维度是否匹配:
julia> [1, 2, 3] .* [4, 5]
ERROR: DimensionMismatch("arrays could not be broadcast to a common size;
got a dimension with lengths 3 and 2")
5.1.2 广播中扩展长度为 1 的维度
所有参与广播的集合的维度必须匹配的规则有一个例外。这个例外指出,单元素维度会通过重复该单元素中存储的值来扩展,以匹配其他集合的大小:
julia> [1, 2, 3] .^ [2]
3-element Vector{Int64}:
1
4
9
你可能会问为什么单元素维度会被扩展。原因是实用的:在大多数情况下,当你的集合在某个维度上只有一个元素时,你希望它被扩展。以下是一个例子:
julia> [1, 2, 3] .^ 2
3-element Vector{Int64}:
1
4
9
在这里,我们正在计算一个向量元素的平方。由于 2 是一个标量,它被解释为在每个维度上大小为 1。大多数人认为在这种情况下,维度扩展应该发生。你会在 Python 和 R 中看到相同的行为。
现在让我们考虑第二个例子:
julia> [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] .* [1 2 3 4 5 6 7 8 9 10]
10×10 Matrix{Int64}:
1 2 3 4 5 6 7 8 9 10
2 4 6 8 10 12 14 16 18 20
3 6 9 12 15 18 21 24 27 30
4 8 12 16 20 24 28 32 36 40
5 10 15 20 25 30 35 40 45 50
6 12 18 24 30 36 42 48 54 60
7 14 21 28 35 42 49 56 63 70
8 16 24 32 40 48 56 64 72 80
9 18 27 36 45 54 63 72 81 90
10 20 30 40 50 60 70 80 90 100
我们在这里创建了一个乘法表。指定的操作之所以有效,是因为我们有一个包含 10 个元素、一列和 10 行的向量,以及一个包含一行和 10 列的 10 元素矩阵。在这种情况下,操作的两边都发生了维度扩展。
这种技术在实践中经常被用来获取所有输入的笛卡尔积。例如,在第二部分,你将了解到当你写下 "x" => sum in DataFrames.jl 时,你要求该包将求和函数应用于数据框的 x 列。一个常见的场景是我们想要将多个函数应用于数据框的多个列。使用广播,这可以简洁地写成以下形式:
julia> ["x", "y"] .=> [sum minimum maximum]
3×3 Matrix{Pair{String, _A} where _A}:
"x"=>sum "x"=>minimum "x"=>maximum
"y"=>sum "y"=>minimum "y"=>maximum
这个表达式要求对 x 和 y 列进行求和、最小值和最大值的计算。它之所以按预期工作,是因为我们使用了与乘法表示例中相同的模式。["x", "y"] 表达式创建了一个包含两个元素的向量(回想一下,Julia 中的向量是列式的;在这种情况下,向量有一列和两行),而 [sum minimum maximum] 表达式创建了一个包含一行和三列的矩阵。
当我们将广播应用于 => 操作符时,我们得到传递给它的参数的笛卡尔积。["x", "y"] 向量的单个列被重复三次以匹配 [sum minimum maximum] 矩阵的列数。同样,[sum minimum maximum] 矩阵的单行被重复两次以匹配 ["x", "y"] 向量的行数。因此,["x", "y"] .=> [sum minimum maximum] 操作产生与以下更冗长的代码相同的结果:
julia> left_matrix = ["x" "x" "x"
"y" "y" "y"]
2×3 Matrix{String}:
"x" "x" "x"
"y" "y" "y"
julia> right_matrix = [sum minimum maximum
sum minimum maximum]
2×3 Matrix{Function}:
sum minimum maximum
sum minimum maximum
julia> left_matrix .=> right_matrix
2×3 Matrix{Pair{String}}:
"x"=>sum "x"=>minimum "x"=>maximum
"y"=>sum "y"=>minimum "y"=>maximum
图 5.1 阐述了 ["x", "y"] .=> [sum minimum maximum] 操作。

图 5.1 ["x", "y"] .=> [sum minimum maximum] 操作的结果是一个 2 × 3 矩阵,因为我们传递了一个包含两个元素的向量和一行三列的矩阵作为参数。
你现在知道,你可以在任何操作符之前添加一个点 (.) 来广播它。那么对于不是操作符的函数呢?这里你也使用一个点 (.),但这次,你在函数名之后附加它。这里有一个例子:
julia> abs.([1, -2, 3, -4])
4-element Vector{Int64}:
1
2
3
4
让我强调一下,仅仅将 abs 函数应用于一个向量会导致错误:
julia> abs([1, 2, 3])
ERROR: MethodError: no method matching abs(::Vector{Int64})
原因与之前相同:绝对值在数学上对数字有定义,但对向量没有定义。当然,你也可以方便地将广播应用于接受多个参数的函数。例如,字符串函数将它的参数连接成一个单一的字符串:
julia> string(1, 2, 3)
"123"
如果我们对这个函数使用广播,我们会得到以下结果:
julia> string.("x", 1:10)
10-element Vector{String}:
"x1"
"x2"
"x3"
"x4"
"x5"
"x6"
"x7"
"x8"
"x9"
"x10"
在这里,我们将标量 x 的维度扩展到与 1:10 范围的长度相匹配。当我们想要自动生成对象的名称时,这种操作相当常见——例如,数据框的列或文件夹中的文件名。
在这里强调很重要,即在操作符之前加前缀点或在函数名后加后缀是一个完全通用的解决方案。这并不是特定预定义操作的硬编码功能。你可以使用广播与任何自定义函数。例如:
julia> f(i::Int) = string("got integer ", i)
f (generic function with 2 methods)
julia> f(s::String) = string("got string ", s)
f (generic function with 2 methods)
julia> f.([1, "1"])
2-element Vector{String}:
"got integer 1"
"got string 1"
在这里,我们为 f 函数定义了两种方法。正如你所见,通过写入 f.,我们自动广播了它,而无需定义任何额外的内容。
5.1.3 保护集合不被广播
在我们回到安斯康姆四重奏数据之前,让我先评论一个常见情况。如果我们不想广播一个集合,但想强制它在所有维度上重复使用,就像它是一个标量一样,我们应该怎么做?为了解释这个问题,让我首先介绍 in 函数。
in 函数
in 函数用于检查某个值是否包含在集合中。例如:
julia> in(1, [1, 2, 3])
true
julia> in(4, [1, 2, 3])
false
为了方便起见,in 也支持中缀表示法:
julia> 1 in [1, 2, 3]
true
julia> 4 in [1, 2, 3]
false
你已经知道这种中缀表示法,因为它用于定义 for 循环中的迭代;请参阅第 2.2 节了解这些循环的工作原理。
现在想象你有一个长向量值,并想检查它们是否包含在向量中。当你尝试没有广播的测试时,它可能不会像你预期的那样工作:
julia> in([1, 3, 5, 7, 9], [1, 2, 3, 4])
false
问题在于向量[1, 3, 5, 7, 9]不是向量[1, 2, 3, 4]的元素,所以你得到 false。为了参考,让我们测试将[1, 3, 5, 7, 9]向量放入我们寻找它的集合中的场景:
julia> in([1, 3, 5, 7, 9], [1, 2, 3, 4, [1, 3, 5, 7, 9]])
true
如预期,这次 in 测试返回 true。回到原始测试,请注意,广播似乎也没有起作用:
julia> in.([1, 3, 5, 7, 9], [1, 2, 3, 4])
ERROR: DimensionMismatch("arrays could not be broadcast to a common size;
got a dimension with lengths 5 and 4")
我们应该如何解决这个问题?解决方案是将我们想要整体重复使用的向量用 Ref 包装起来。这样,我们将保护这个对象不被迭代。相反,它将从 Ref 中解包,并像标量一样进行广播处理,因此这个值将被重复以匹配其他容器的维度:
julia> in.([1, 3, 5, 7, 9], Ref([1, 2, 3, 4]))
5-element BitVector:
1
1
0
0
0
这次我们得到了预期的结果。
Julia 中的 Ref 是什么?
在 Julia 中,当你写下 r = Ref(x)时,你创建了一个零维容器,它将 x 值作为其唯一元素存储。你可以通过写入 r[]从 Ref 值 r 中检索 x 对象(注意,我们在索引语法中不传递任何索引,因为 r 对象是零维的)。类型名为 Ref;你可以把它想象成 r 是 x 的引用。
由于 Ref 对象是零维的,并且存储了恰好一个元素,它们在每个维度上的长度都是 1。因此,如果你在广播中使用 r 对象,它存储的 x 值将在所有所需的维度中使用,遵循第 5.1.2 节中讨论的扩展规则。
在输出中,请注意布尔 true 被打印为 1,布尔 false 被打印为 0。这种显示选择允许更方便地检查包含布尔值的大型矩阵。为了了解为什么这很有用,考虑我们想要使用 isodd 函数来检查第 5.1.2 节中创建的乘法表中的哪些条目是奇数:
julia> isodd.([1, 2, 3, 4, 5, 6, 7, 8, 9, 10] .* [1 2 3 4 5 6 7 8 9 10])
10×10 BitMatrix:
1 0 1 0 1 0 1 0 1 0
0 0 0 0 0 0 0 0 0 0
1 0 1 0 1 0 1 0 1 0
0 0 0 0 0 0 0 0 0 0
1 0 1 0 1 0 1 0 1 0
0 0 0 0 0 0 0 0 0 0
1 0 1 0 1 0 1 0 1 0
0 0 0 0 0 0 0 0 0 0
1 0 1 0 1 0 1 0 1 0
0 0 0 0 0 0 0 0 0 0
在这个例子中,你可以看到广播操作可以在单个表达式中链接在一起。在这种情况下,我们广播了乘法*和 isodd 函数。
为了参考,让我展示一下如果我们将其元素类型更改为 Any(第 5.2 节提供了更多关于类型参数的详细信息),这个矩阵将如何显示:
julia> Matrix{Any}(isodd.([1, 2, 3, 4, 5, 6, 7, 8, 9, 10] .*
[1 2 3 4 5 6 7 8 9 10]))
10×10 Matrix{Any}:
true false true false true false true false true false
false false false false false false false false false false
true false true false true false true false true false
false false false false false false false false false false
true false true false true false true false true false
false false false false false false false false false false
true false true false true false true false true false
false false false false false false false false false false
true false true false true false true false true false
false false false false false false false false false false
这次,打印 true 和 false 以避免与可能存储在此矩阵中的整数 1 和 0 混淆,因为其元素类型是 Any。然而,在我看来,分析这样的打印输出比以前不太方便。
为了练习你所学的,尝试以下练习,这是处理数据时的常见任务。
练习 5.1:parse 函数可以用来将字符串转换为数字。例如,如果你想将字符串解析为整数,写 parse(Int, "10")以获取整数 10。假设你被给了一个包含字符串["1", "2", "3"]的向量。你的任务是创建一个包含这些字符串中数字的整数向量。
5.1.4 使用广播分析安斯康姆四重奏数据
现在我们准备回到我们的安斯康姆四重奏数据。首先,让我们初始化 aq 变量,就像我们在列表 4.1 中做的那样:
julia> aq = [10.0 8.04 10.0 9.14 10.0 7.46 8.0 6.58
8.0 6.95 8.0 8.14 8.0 6.77 8.0 5.76
13.0 7.58 13.0 8.74 13.0 12.74 8.0 7.71
9.0 8.81 9.0 8.77 9.0 7.11 8.0 8.84
11.0 8.33 11.0 9.26 11.0 7.81 8.0 8.47
14.0 9.96 14.0 8.1 14.0 8.84 8.0 7.04
6.0 7.24 6.0 6.13 6.0 6.08 8.0 5.25
4.0 4.26 4.0 3.1 4.0 5.39 19.0 12.50
12.0 10.84 12.0 9.13 12.0 8.15 8.0 5.56
7.0 4.82 7.0 7.26 7.0 6.42 8.0 7.91
5.0 5.68 5.0 4.74 5.0 5.73 8.0 6.89]
11×8 Matrix{Float64}:
10.0 8.04 10.0 9.14 10.0 7.46 8.0 6.58
8.0 6.95 8.0 8.14 8.0 6.77 8.0 5.76
13.0 7.58 13.0 8.74 13.0 12.74 8.0 7.71
9.0 8.81 9.0 8.77 9.0 7.11 8.0 8.84
11.0 8.33 11.0 9.26 11.0 7.81 8.0 8.47
14.0 9.96 14.0 8.1 14.0 8.84 8.0 7.04
6.0 7.24 6.0 6.13 6.0 6.08 8.0 5.25
4.0 4.26 4.0 3.1 4.0 5.39 19.0 12.5
12.0 10.84 12.0 9.13 12.0 8.15 8.0 5.56
7.0 4.82 7.0 7.26 7.0 6.42 8.0 7.91
5.0 5.68 5.0 4.74 5.0 5.73 8.0 6.89
julia> using Statistics
我们将使用广播来重现我们在 4.1 节中执行的两个任务:计算每个变量的平均值和计算确定系数。
我们首先从计算 aq 矩阵的列平均值开始。我们想要将平均值函数应用于矩阵的每一列。在思考如何做到这一点时,我们注意到我们需要将平均值函数广播到 aq 的列集合上。幸运的是,我们知道 eachcol 函数给我们提供了这样的集合;因此,我们可以写出这个:
julia> mean.(eachcol(aq))
8-element Vector{Float64}:
9.0
7.500909090909093
9.0
7.500909090909091
9.0
7.500000000000001
9.0
7.50090909090909
注意 mean 后面的点(.),这意味着我们想要将此函数广播到由 eachcol(aq)产生的集合上。如果我们忘记写点,我们会得到以下结果:
julia> mean(eachcol(aq))
11-element Vector{Float64}:
8.6525
7.4525
10.47125
8.56625
9.35875
10.492500000000001
6.3375
7.03125
9.71
6.92625
5.755000000000001
由于 eachcol(aq)是由构成 aq 矩阵列的八个向量组成的集合,因此平均值函数计算它们的平均值;也就是说,该函数将这八个向量的总和除以 8。因此,我们得到一个包含 aq 矩阵行平均值的向量(注意结果有 11 个元素,这是 aq 矩阵的行数),我们想要计算其列的平均值。
作为第二个应用,让我们使用广播来重写计算确定系数的函数。让我提醒你原始实现:
function R²(x, y)
X = [ones(11) x]
model = X \ y
prediction = X * model
error = y - prediction
SS_res = sum(v -> v ^ 2, error)
mean_y = mean(y)
SS_tot = sum(v -> (v - mean_y) ^ 2, y)
return 1 - SS_res / SS_tot
end
如果我们想要使用广播,我们可以这样写:
function R²(x, y)
X = [ones(11) x]
model = X \ y
prediction = X * model
SS_res = sum((y .- prediction) .^ 2)
SS_tot = sum((y .- mean(y)) .^ 2)
return 1 - SS_res / SS_tot
end
如你所见,我们改变了 SS_res 和 SS_tot 的公式。在两种情况下,我们都使用了点(.)两次。例如,在(y .- prediction) .^ 2 中,我们广播了减法和指数运算。
Julia 中广播的效率
Julia 的一个重要特性,使其与 R 和 Python 区分开来的是,如果它在单个表达式中遇到多个连续的广播操作,它会一次性执行操作而不分配任何中间对象。这个特性被称为广播融合,大大提高了复杂广播操作的效率。
广播融合可以很高效,因为,如第一章所述,Julia 将你的程序作为一个整体编译,所以当遇到广播操作时,编译器可以完全优化要执行的本地代码。这与 R 和 Python 不同,在 R 和 Python 中,对广播操作的支持通常是用 C 语言等实现的,并存储在预编译的二进制文件中,用于有限的预定义函数集。
如果你想了解更多关于 Julia 语言这个功能如何工作的信息,我建议你从 Matt Bauman 的“可扩展的广播融合”(mng.bz/G1OR)开始阅读。
到现在为止,你已经知道了四种迭代应用操作到集合元素的方法:
-
使用 for 循环
-
使用列表推导式
-
使用 map 函数(以及其他类似的高阶函数,它们将函数作为它们的参数)
-
使用广播
你可能正在问自己在哪些情况下应该使用哪个选项。幸运的是,这主要是一个方便性和代码可读性的问题。在你的项目中,使用对你来说最容易使用且能产生最易读代码的选项。Julia 的一个伟大特性是,所有这些选项都很快。大多数时候,你不会因为选择其中一个而牺牲性能。
我说“大多数时候”,因为存在这个规则的例外。除了本节中已经讨论的差异之外,最重要的例外之一是,如果你使用 for 循环或 map 函数,你可以选择使用 Threads 模块或 ThreadsX.jl 包来使操作利用处理器上的所有核心。轻松支持代码的多线程执行是区分 Julia 与 R 和 Python 的一个特性。第二部分将展示如何在项目中利用多线程的例子。
5.2 定义具有参数类型的函数
在本节中,我们将编写自己的函数来计算两个向量的协方差。正如你可能猜到的,Statistics 模块中的 cov 函数执行这个计算,但将此函数作为练习来编写是有教育意义的。我们的目标是编写一个函数,该函数接受包含实数值的两个向量作为参数,并返回协方差。我的要求的关键部分是函数应该接受包含实数值的两个向量作为参数。在本节中,你将学习如何指定此类限制。
定义具有复杂类型限制的函数具有挑战性。幸运的是,在你的大多数代码中,你不需要编写自己的方法,因此不需要对这一主题有深入的了解。然而,由于用 Julia 编写的包大量使用这些功能,你必须了解这些概念,以便能够理解这些包提供的函数接受哪些参数,以及在使用它们时如何阅读 Julia 产生的错误消息。
5.2.1 Julia 中大多数集合类型都是参数化的
在第三章中,你学习了 Julia 的类型系统和如何定义方法。在本章中,我们将讨论与集合一起工作。你可能已经注意到,大多数表示集合的类型都是参数化的:它们指定了可以存储在其中的数据类型。以下是一些示例:
julia> []
Any[]
julia> Dict()
Dict{Any, Any}()
在这里,我们创建了一个空向量和空字典。它们可以存储任何值,这由 Any 参数表示,因此它们就像 Python 中的列表和字典一样工作。
对于向量,你可以通过在开方括号前加上类型来指定它们的元素类型:
julia> Float64[1, 2, 3]
3-element Vector{Float64}:
1.0
2.0
3.0
注意,尽管我们输入了 1、2 和 3 作为整数,但它们被转换为 Float64,因为我们要求结果向量应包含此类值。同样,对于字典,我们可以这样写:
julia> Dict{UInt8, Float64}(0 => 0, 1 => 1)
Dict{UInt8, Float64} with 2 entries:
0x00 => 0.0
0x01 => 1.0
如你所见,我们强制将键和值分别转换为 UInt8 和 Float64 类型。作为旁注,请注意 Julia 使用 0x 前缀打印无符号整数,这些值使用十六进制表示。例如,让我们指定从 Int 到 UInt32 的显式转换:
julia> UInt32(200)
0x000000c8
作为最后的例子,我们创建了一个可以存储任何 Real 值的向量:
julia> Real[1, 1.0, 0x3]
3-element Vector{Real}:
1
1.0
0x03
注意这次,没有发生存储值的转换,因为 Int、Float64 和 UInt8 类型是 Real 类型的子类型(正如你在第三章所知)。要检查这一点,请运行 typeof.(Real[1, 1.0, 0x3]),你将得到一个[Int64, Float64, UInt8]向量作为结果。
在我们继续前进之前,让我介绍一下 eltype 函数。此函数允许我们提取集合可以存储的元素类型。以下是一些示例:
julia> v1 = Any[1, 2, 3]
3-element Vector{Any}:
1
2
3
julia> eltype(v1)
Any
julia> v2 = Float64[1, 2, 3]
3-element Vector{Float64}:
1.0
2.0
3.0
julia> eltype(v2)
Float64
julia> v3 = [1, 2, 3]
3-element Vector{Int64}:
1
2
3
julia> eltype(v3)
Int64
julia> d1 = Dict()
Dict{Any, Any}()
julia> eltype(d1)
Pair{Any, Any}
julia> d2 = Dict(1 => 2, 3 => 4)
Dict{Int64, Int64} with 2 entries:
3 => 4
1 => 2
julia> eltype(d2)
Pair{Int64, Int64}
对于向量,我们只得到类型。对于字典,我们得到一个 Pair 类型,因为,如前所述,在 Julia 中键值组合有一个 Pair 类型:
julia> p = 1 => 2
1 => 2
julia> typeof(p)
Pair{Int64, Int64}
5.2.2 参数化类型的子类型规则
看过这些例子后,我们现在准备回到我们的任务,即定义一个函数,该函数接受两个包含实数值的向量并返回它们的协方差。我们希望该函数接受任何向量。
你从第三章中已经知道我们应该使用 AbstractVector 类型。我们还希望函数只接受这些向量中的实数值。同样,我们知道这些向量的元素类型应该是 Real。
因此,我们的第一个假设是 AbstractVector{Real} 应该是正确的类型。让我们通过使用第三章中讨论的 isa 测试来验证这个假设:
julia> [1, 2, 3] isa AbstractVector{Int}
true
julia> [1, 2, 3] isa AbstractVector{Real}
false
我们看到,正如预期的那样,[1, 2, 3] 的类型是 AbstractVector{Int} 的子类型,但令人惊讶的是,它不是 AbstractVector{Real} 的子类型。这种 Julia 中参数的行为在计算机科学中被称为 不变性:尽管 Int 是 Real 的子类型,但 AbstractVector{Int} 不是 AbstractVector{Real} 的子类型。你可以在 Julia 手册的“参数复合类型”部分找到对这个设计决策的深入讨论(mng.bz/z5EX)。
类型为 AbstractVector{Real} 子类型的向量必须允许存储任何实数值。你在 5.2.1 节中看到了向量 Real[1, 1.0, 0x3],并且我们在那里检查了确实其元素有不同的类型。因此,例如,Vector{Real} 不能像 Vector{Int} 一样高效地存储在内存中。
我现在将专注于解释如何指定元素类型是 Real 子类型的向量类型。这种情况的语法是 AbstractVector{<:Real}。<: 序列表示向量的元素类型可以是 Real 的任何子类型,而不仅仅是 Real。等价地,我们也可以使用在第三章中讨论过的 where 关键字:
julia> AbstractVector{<:Real} == AbstractVector{T} where T<:Real
true
AbstractVector{T} 其中 T<:Real 的形式遇到得较少,但如果我们想在代码中稍后引用存储向量元素类型的变量 T,它可能是有用的。
为了总结我们的讨论,让我给出几个类型及其含义的具体例子。这个例子是围绕 Vector 类型构建的:
-
Int 是 Real 的子类型。这是因为 Real 是一个指代多个数值类型的抽象概念;同样,Real 也是 Any 的子类型。
-
Vector{Int} 不是 Vector{Real} 的子类型。这是因为,正如你在本节中看到的,Vector{Int} 和 Vector{Real} 都可以有实例。一个是只能存储整数的容器。另一个是可以存储任何实数值的容器。这两个是两个具体且不同的容器。没有一个是从另一个派生的子类型。
-
Vector{<:Real},或者等价地,Vector{T} 其中 T<:Real,是一种描述可以存储实数值的所有容器的联合的方式。Vector{<:Real} 是一个抽象概念,指代多个容器。Vector{Int} 和 Vector{Real} 都是 Vector{<:Real} 的子类型。
-
Vector、Vector{<:Any} 和 Vector{T}(其中 T 是类型)都是描述所有具有 Vector 类型但没有限制其元素类型的容器联合的方式。这与 Vector{Any} 不同,Vector{Any} 是一个具体类型,它可以有一个实例:它是一个可以存储任何值的向量。请注意,尽管 Vector{Any} 是 Vector{<:Any} 的子类型,而 Vector{<:Any} 又是 Any 的子类型(因为 Julia 中的每个类型都是 Any 的子类型)。
图 5.2 展示了这些关系。

图 5.2 在这个子类型关系的示例中,一个框代表一个类型。如果一个类型是给定类型的子类型,它就被放在框内。请注意,尽管 Int 是 Real 的子类型,但 Vector{Int} 不是 Vector{Real} 的子类型。同样,尽管 Real 是 Any 的子类型,但 Vector{Real} 也不是 Vector{Any} 的子类型。
5.2.3 使用子类型规则定义协方差函数
那么,我们应该如何定义我们的协方差函数?这里是一个完整的方法:
julia> using Statistics
julia> function ourcov(x::AbstractVector{<:Real},
y::AbstractVector{<:Real})
len = length(x)
@assert len == length(y) > 0 ❶
return sum((x .- mean(x)) .* (y .- mean(y))) / (len - 1)
end
ourcov (generic function with 1 method)
❶ 第 3.4 节解释了 @assert 宏的工作方式。第 2.3.1 节解释了如何组合几个逻辑条件。
在前面的代码中,我们使用广播来计算协方差。让我们首先检查我们的ourcov函数是否正确工作:
julia> ourcov(1:4, [1.0, 3.0, 2.0, 4.0])
1.3333333333333333
julia> cov(1:4, [1.0, 3.0, 2.0, 4.0])
1.3333333333333333
它看起来确实按预期工作。请注意,在代码中,我们混合了一系列整数和一个浮点数值向量,它们被接受并正确处理。然而,如果我们传递一个元素类型不是 Real 子类型的集合,即使我们不更改集合存储的具体值,函数也会失败:
julia> ourcov(1:4, Any[1.0, 3.0, 2.0, 4.0])
ERROR: MethodError: no method matching ourcov(::UnitRange{Int64},
::Vector{Any})
Closest candidates are:
ourcov(::AbstractVector{var"#s3"} where var"#s3"<:Real,
::AbstractVector{var"#s2"} where var"#s2"<:Real) at REPL[48]:1
这次,函数失败了,因为我们的第二个参数是一个元素类型为 Any 的向量,而 Any 不是 Real 的子类型。
在我总结这一节之前,让我回答一个常见问题。如果你有一个包含宽元素类型(例如,Any)的容器,并且想要将其缩小到存储在集合中的元素类型,该怎么办?幸运的是,这很简单。你只需要将恒等函数(返回其参数)广播到集合上。然后,Julia 实现的广播机制将为你执行元素类型的缩小。这里你可以看到它是如何工作的:
julia> x = Any[1, 2, 3]
3-element Vector{Any}:
1
2
3
julia> identity.(x)
3-element Vector{Int64}:
1
2
3
julia> y = Any[1, 2.0]
2-element Vector{Any}:
1
2.0
julia> identity.(y)
2-element Vector{Real}:
1
2.0
5.3 与 Python 集成
在本节中,你将学习如何在处理集合时将 Julia 与 Python 集成。你会发现 Julia 和 Python 之间的集合类型转换是自动完成的。知道如何在 Julia 中使用 Python 代码是有用的,因为在较大的项目中,你可能需要构建使用这两种技术开发的软件组件。我选择了我们将使用的示例,以进一步强化你对如何在 Julia 中处理数组和使用广播的理解。
我选择展示与 Python 的集成,因为目前它是一种非常流行的语言。在第十章中,您将学习如何集成 Julia 和 R。如果您想从 Julia 调用 C 或 Fortran 代码,请参阅 Julia 手册 mng.bz/091l。其他语言的绑定由包提供——例如,使用 Cxx.jl (github.com/JuliaInterop/Cxx.jl) 与 C++ 集成或使用 JavaCall.jl (github.com/JuliaInterop/JavaCall.jl) 与 Java 集成。
5.3.1 使用 t-SNE 进行降维前的数据准备
作为 Julia 与 Python 集成的示例应用,我将向您展示如何使用 t-SNE 算法进行降维。t-Distributed Stochastic Neighbor Embedding (t-SNE) 是一种统计方法,它为属于高维空间中的每个数据点在低维空间中分配一个位置 (lvdmaaten.github.io/tsne/)。在这种情况下,我们将使用二维空间作为目标,因为它可以很容易地在图中可视化。t-SNE 以一种方式执行映射,使得高维源空间中的相似对象在低维目标空间中是邻近的点,而不相似的对象是远离的点。
我们从在五维空间中生成随机数据开始,我们稍后希望将其嵌入到二维空间中:
julia> using Random
julia> Random.seed!(1234);
julia> cluster1 = randn(100, 5) .- 1
100×5 Matrix{Float64}:
-0.0293437 -0.737544 -0.613869 -1.31815 -2.95335
-1.97922 -1.02224 -1.74252 -2.33397 -2.00848
⋮
-1.55273 -1.09341 -0.823972 -3.41422 -2.21394
-4.40253 -1.62642 -1.01099 -0.926064 0.0914986
julia> cluster2 = randn(100, 5) .+ 1
100×5 Matrix{Float64}:
1.57447 1.40369 1.44851 1.27623 0.942008
2.16312 1.88732 2.51227 0.533175 -0.520495
⋮
1.47109 2.61912 1.80582 1.18953 1.41611
2.77582 1.53736 -0.805129 -0.315228 1.35773
首先,我们使用 Julia 中的 Random.seed!(1234) 命令来设置随机数生成器的种子。函数名称后缀为 ! 是因为它会修改全局随机数生成器的状态。这将确保我向您展示的数据与您在相同版本的 Julia 下运行此代码时获得的数据相同。如果您希望在每次运行此代码时都生成不同的随机数,请跳过设置随机数生成器的种子。
接下来,使用 randn 函数,我们生成两个 100 行 5 列的矩阵。它们存储的值是从标准正态分布中随机抽取的。通过广播,我们从 cluster1 矩阵的所有条目中减去 1,并将 1 添加到 cluster2 矩阵的所有条目中。这样,我们就将两个矩阵中存储的点分离开了。来自 cluster 1 的数据大多是负数,而在 cluster 2 中,我们大多数条目是正数。
现在,我们使用 vcat 函数垂直连接这些矩阵,创建一个 200 行 5 列的单个矩阵。我们称这个矩阵为 data5,因为它有五个列:
julia> data5 = vcat(cluster1, cluster2)
200×5 Matrix{Float64}:
-0.0293437 -0.737544 -0.613869 -1.31815 -2.95335
-1.97922 -1.02224 -1.74252 -2.33397 -2.00848
-0.0981391 -1.39129 -1.87533 -1.76821 -1.23108
-1.0328 -0.972379 0.600607 -0.0713489 -1.16386
⋮
1.2327 2.37472 1.31467 -0.290594 3.00592
-0.198159 -0.211778 -0.726857 0.194847 2.65386
1.47109 2.61912 1.80582 1.18953 1.41611
2.77582 1.53736 -0.805129 -0.315228 1.35773
我们将想要查看,在使用 t-SNE 算法将维度降低到二维后,我们是否能够通过视觉确认这两个簇确实被分开了。
5.3.2 从 Julia 调用 Python
首先,我们需要使用 PyCall.jl 包中的 pyimport 函数加载所需的 Python 包:
julia> using PyCall
julia> manifold = pyimport("sklearn.manifold")
PyObject <module 'sklearn.manifold' from
'~\\.julia\\conda\\3\\lib\\site-packages\\sklearn\\manifold\\__init__.py'>
此操作可能在你的机器上失败。如果发生这种情况,原因可能是 Python 配置不正确或 Python 中的 sklearn 未安装。你应该会收到有关需要执行哪些操作来修复此问题的信息。如果 Python 中的 sklearn 未安装,以下代码是添加它的标准方法:
using Conda
Conda.add("scikit-learn")
然而,在某些操作系统配置下,此操作可能会失败。请参阅以下侧边栏以获取更多选项。
使用 PyCall.jl 包配置与 Python 的集成
PyCall.jl 包允许你从 Julia 语言与 Python 进行交互。它允许你从 Julia 导入 Python 模块,调用 Python 函数(类型自动转换),甚至可以从 Julia 中评估整个 Python 代码块。
当你安装 PyCall.jl 时,默认情况下,在 Mac 和 Windows 系统上,它将为 Julia 安装一个私有的最小 Python 分布。在 GNU/Linux 系统上,该包将使用你的 PATH 中可用的 Python 安装。或者,你可以使用 PyCall.jl GitHub 页面(mng.bz/vXo1)上解释的不同版本的 Python。
如果你使用 Mac 或 Windows 操作系统,并且使用 Python 的默认配置,你可以使用 Conda.jl 包向 Julia 的 Python 分布中添加包。如果你使用 GNU/Linux 系统,那么默认情况下,你应该能够通过使用你在 Python 安装中使用的标准工具来添加包。
不幸的是,与 Julia 内置的标准包管理器不同,在机器上正确配置 Python 环境并安装包有时可能具有挑战性。PyCall.jl 包维护者已尝试在大多数情况下使此过程自动运行。但是,如果失败,我建议你参考 PyCall.jl(github.com/JuliaPy/PyCall.jl)和 Conda.jl(github.com/JuliaPy/Conda.jl)页面,以获取解决这些问题的更详细说明。
在从 Python 导入 sklearn.manifold 并将其绑定到 manifold 变量后,我们就准备好使用 t-SNE 算法了。我们将结果存储在 data2 变量中,因为降维后的结果矩阵有两列:
julia> tsne = manifold.TSNE(n_components=2, init="random",
learning_rate="auto", random_state=1234)
PyObject TSNE(init='random', learning_rate='auto', random_state=1234)
julia> data2 = tsne.fit_transform(data5)
200×2 Matrix{Float32}:
1.25395 -14.9826
0.448442 -12.2407
-2.0488 -10.6652
2.19538 -3.94876
⋮
6.23544 10.1046
5.49633 6.37504
-1.82243 13.8231
5.05417 13.2529
如果你参考了 scikit-learn 文档中关于使用 t-SNE 算法的示例(mng.bz/K0oZ),你会发现使用 Python 在 Julia 中几乎是透明的:
-
你可以以与在 Python 中调用它们完全相同的方式调用 Python 函数。特别是,你可以使用点(.)以与 Python 中相同的方式引用对象。
-
Julia 和 Python 对象之间发生自动转换,因此你无需考虑它。
这种集成级别意味着从 Julia 中使用 Python 对开发者来说几乎不需要动脑筋。根据我的经验,大多数时候,如果你想把一些 Python 代码移植到 Julia,只需修复语法差异就足够了,而且一切都会正常工作。例如,在 Julia 中,字符串字面量需要双引号("),而在 Python 中通常使用单引号(')。
本节仅展示了将 Julia 与 Python 集成的最小示例。如果你想了解更多细节,例如可能的集成选项,请查看 PyCall.jl 包网站github.com/JuliaPy/PyCall.jl。
5.3.3 t-SNE 算法结果的可视化
为了总结我们的分析,让我们使用散点图来绘制 data2 矩阵。我们将用不同的填充颜色来着色前 100 个点,代表簇 1,而最后 100 个点来自簇 2:
julia> using Plots
julia> scatter(data2[:, 1], data2[:, 2];
color=[fill("black", 100); fill("gold", 100)],
legend=false)
在此代码中,注意[fill("black", 100); fill("gold", 100)]表达式。首先,使用 fill 函数,我们创建了两个存储 100 个常量值的向量,代表我们想要使用的颜色。接下来,在方括号内,使用分号(;),我们垂直连接这两个向量,创建一个包含 200 个元素的向量,并将其作为颜色关键字参数传递给 scatter 函数。
图 5.3 显示了结果图。观察可知,正如预期的那样,我们看到了来自簇 1 和簇 2 的点之间的分离(除了簇 1 中的一个异常值)。

图 5.3 t-SNE 嵌入结果的可视化。簇 1 和簇 2 的数据(用不同的填充颜色表示)被分离。该算法帮助我们识别簇 1 中的一个异常值;这是在嵌入空间中位于簇 2 的点更近的黑点。
练习 5.2 重复 5.3 节中展示的分析,但在创建簇 1 和簇 2 的数据时,不是加 1 和减 1,而是分别加 0.4 和减 0.4。这将减少五维空间中两个簇之间的分离。检查这是否会减少由 t-SNE 生成的二维空间中的分离。
摘要
-
Julia 提供了四种迭代集合并转换它们的重要方式:循环、map 函数(以及其他类似的高阶函数)、列表推导式和广播。每种方法在处理数据时都有略微不同的规则。因此,你应该根据具体情况选择其中一种。
-
Julia 中的大多数函数都是定义为在标量上工作的。如果你想逐元素应用一个函数到集合上,你必须使用 Julia 提供的一种允许你迭代集合的方法。
-
在 Julia 中,广播是一种将函数应用于值集合(在其他语言中通常称为 向量化 的操作)。你可以通过在函数后添加一个点 (.) 来广播任何函数(如 sin 或 log)。同样,要向量化一个运算符(如 * 或 /),在它前面加上一个点 (.)。
-
Julia 中的广播是高效的,因为它使用了广播融合。在执行复杂的广播操作时,Julia 不需要为存储数据处理中间结果的对象分配内存。
-
在 Julia 中,类似于 R 和 Python,广播会自动扩展长度为 1 的维度。记住这个规则很重要,因为如果你忘记了它,你可能会对广播操作的结果感到惊讶。
-
如果你有一个要传递集合的广播操作,而你希望该集合被视为标量,请将其包裹在 Ref 中。这种方法通常在通过 in 函数执行对参考表的查找时使用。
-
当与集合一起工作时,你应该了解 Julia 的参数化类型的子类型规则。编写 Vector{Real} 指定了一个值可以取的类型。这个值是一个可以存储任何实数的 Vector。另一方面,Vector{<:Real} 用于表示任何元素类型是 Real 子类型的 Vector 的超类型。没有值可以有 Vector{<:Real} 类型,因为它不是一个叶类型,也不是具体的。因此,Vector{Int} 是 Vector{<:Real} 的子类型,但不是 Vector{Real} 的子类型(回想第三章,在 Julia 中,如果一个类型可以有实例,则不允许它有子类型)。
-
你可以通过使用 PyCall.jl 包将 Julia 与 Python 集成。当你想在 Julia 项目中使用 Python 代码时,这种集成通常是必需的。
-
PyCall.jl 包提供的 Julia 与 Python 的集成允许你以与在 Python 中调用它们完全相同的方式调用 Python 函数,并且自动在 Julia 和 Python 格式之间转换集合。这意味着在 Julia 中使用 Python 功能很容易,并且只需对 Python 代码进行最小更改即可在 Julia 项目中使用它们。
6 处理字符串
本章涵盖
-
Julia 字符串的 UTF-8 编码;字节与字符索引
-
字符串操作:插值、分割、使用正则表达式、解析
-
处理符号
-
使用 InlineStrings.jl 包处理固定宽度字符串
-
使用 PooledArrays.jl 包压缩字符串向量
在本章中,你将学习如何在 Julia 语言中处理文本数据。文本数据存储在字符串中。字符串 是你在进行数据科学项目时最常遇到的数据类型之一,尤其是在涉及自然语言处理任务时。
作为字符串处理的实际应用,我们将分析被 Twitter 用户评分的电影类型。我们希望了解哪种电影类型最常见,以及这种类型的相对频率如何随电影年份变化。
对于这次分析,我们将使用 movies.dat 文件。文件 URL 是 mng.bz/9Vao,该文件在 GitHub 仓库 github.com/sidooms/MovieTweetings 下以 MIT 许可证共享。
我们将根据以下步骤分析电影类型数据,这些步骤在本章的后续部分中描述,并在图 6.1 中展示:
-
从网络获取数据。
-
在 Julia 中读取数据。
-
解析原始数据以提取每部分析电影的年份和类型列表。
-
创建频率表以找出哪种电影类型最常见。
-
按年份创建最常见类型的流行度图表。

图 6.1 分析步骤:每个步骤列出使用的重要 Julia 函数和提供它们的包
通过分析,你将了解 Julia 中的字符串是 UTF-8 编码的含义,以及你在处理字符串时应如何考虑这一事实。
在本章末尾,我们将讨论处理字符串时的性能问题。你将了解以下内容:
-
在分析文本数据时使用符号而不是字符串
-
使用 InlineStrings.jl 包提供的固定宽度字符串
-
使用 PooledArrays.jl 包压缩字符串向量
6.1 获取和检查数据
在大多数数据科学工作流程中,你将面临的第一项任务是获取数据并在开始分析之前读取它。因此,在本节中,我们从学习如何从网络下载源文件并检查其内容开始(为了你的方便,该文件也存储在 GitHub 仓库中,与本书的源代码一起)。
6.1.1 从网络下载文件
首先,下载数据,如下所示。
列表 6.1 从 GitHub 获取 movies.dat 文件
julia> import Downloads
julia> Downloads.download("https://raw.githubusercontent.com/" *
"sidooms/MovieTweetings/" *
"44c525d0c766944910686c60697203cda39305d6/" *
"snapshots/10K/movies.dat",
"movies.dat")
"movies.dat"
我们使用来自 Downloads 模块的 download 函数,该函数接受两个参数:要获取的文件的 URL 位置以及它应该保存的位置路径。在这种情况下,我们将文件保存为 movies.dat 到 Julia 的工作目录中。
在 Julia 1.6 之前的版本中下载文件
在 Julia 1.6 之前的版本中,下载功能无需使用 Downloads 模块即可使用。尽管这个功能在 Julia 1.7(本书使用的版本)中仍然可用,但它已被弃用,因此我建议您使用 Downloads 模块。
注意,在上面的示例中,download 函数的两个参数都是字符串字面量。请注意两个重要点:
-
字符串字面量用双引号(")括起来。
-
您可以使用乘法运算符(*)连接字符串字面量。在上面的示例中,我们将一个长字符串拆分为多行代码,并通过使用 * 连接它们(在 Python 中,您会使用加法运算符(+)来连接字符串)。
让我简要地评论一下 Julia 字符串支持的一些标准特性。
6.1.2 使用字符串构造的常用技术
第一个方便的特性是,您可以通过在字符串字面量中使用 $ 来将变量插入到字符串中。以下是一个示例:
julia> x = 10
10
julia> "I have $x apples"
"I have 10 apples"
在此代码中,绑定到变量 x 的值被插入到字符串中,正如我们在字符串字面量中写入 $x 一样。您也可以以这种方式插入更复杂的表达式,但此时您需要将它们括在括号中:
julia> "I have $(2 * x) apples"
"I have 20 apples"
第二个特性是,您可以在字符串字面量中使用 C 的传统转义输入形式(en.cppreference.com/w/cpp/language/escape);例如,要创建包含换行符的字符串,请使用 \n 序列。例如,字符串字面量 "a\nb" 由三个字符组成:a,后面跟着一个换行符,最后是 b。
除了标准转义序列之外,Julia 还引入了两个额外的转义序列。要写入 $,您需要使用 . 转义它。未转义的 $ 用于插值,如我之前解释的。以下是一个 $ 转义序列在起作用的示例,显示仅使用 $ 会导致错误:
julia> "I have \$100."
"I have \$100\. ❶
julia> "I have $100."
ERROR: syntax: invalid interpolation syntax: "$1"
❶ Julia 通过使用转义形式在交互会话中显示字符串。
第二个扩展是 \ 紧接着一个换行符。这个序列允许您将长字符串拆分为多行。因此,我们可以在列表 6.1 中使用 *,而不是这样写以获得相同的结果:
Downloads.download("https://raw.githubusercontent.com/\
sidooms/MovieTweetings/\
44c525d0c766944910686c60697203cda39305d6/\
snapshots/10K/movies.dat",
"movies.dat")
在这种情况下,\ 后面的换行符以及下一行中的任何前导空白(通常用于代码缩进)都被忽略,正如您在这里看到的:
julia> "a\
b\
c"
"abc"
有时你可能想避免 C 的转义输入形式的特殊处理和插值。你可以通过在字符串字面量前使用 raw 前缀轻松避免它们(这些字面量被称为原始字符串字面量)。我在 Windows 上工作时最常使用这个特性来编写路径。以下是一个例子。如果你尝试在一个标准的字符串字面量中写入标准的 Windows 路径,很可能会出错:
julia> "C:\my_folder\my_file.txt"
ERROR: syntax: invalid escape sequence
这个错误发生是因为 Julia 将\m 视为无效的转义序列。我们可以通过使用 raw 前缀轻松解决这个问题:
julia> raw"C:\my_folder\my_file.txt"
"C:\\my_folder\\my_file.txt"
这次一切正常。请注意,字符串仍然显示为标准字符串。每个\字符显示为\,因为这是在标准字符串中解释为\的转义序列。如果你想打印字符串的未装饰文本表示,请使用 print 函数:
julia> print(raw"C:\my_folder\my_file.txt")
C:\my_folder\my_file.txt
在 Julia 代码中,你可能会遇到第二种特殊的字符串字面量,即三引号字符串。这些字面量以三个引号(""")开始和结束。它们通常用于创建跨越多行的长文本块。在这本书中,我们不使用这些字面量,但如果你对细节感兴趣,可以在 Julia 手册中找到它们,网址为mng.bz/jAjp。
现在你已经了解了创建字符串字面量的基础知识,让我们看看如何从磁盘读取字符串。首先,我们使用 isfile 函数检查 movies.dat 文件是否已下载,以查看文件是否存在于当前工作目录中:
julia> isfile("movies.dat")
true
6.1.3 读取文件内容
函数返回 true,这意味着文件存在。让我们逐行读取其内容,并将结果绑定到下一列表中的 movies 变量。
列表 6.2 将 movies.dat 文件读入向量
julia> movies = readlines("movies.dat")
3096-element Vector{String}:
"0002844::Fantômas - À l'ombre de la guillotine (1913)::Crime|Drama"
"0007264::The Rink (1916)::Comedy|Short"
"0008133::The Immigrant (1917)::Short|Comedy|Drama|Romance"
"0012349::The Kid (1921)::Comedy|Drama|Family"
⋮
"2748368::Neil (2013)::Short|Comedy"
"2750600::A Different Tree (2013)::Short|Drama|Family"
"2763252::Broken Night (2013)::Short|Drama"
"2769592::Kiss Shot Truth or Dare (2013)::Short"
readlines 函数读取文件中的所有行,作为一个字符串向量。向量中的每个字符串代表我们数据中的一行。
观察数据,我们可以看到关于每部电影(文件中的一行)的条目具有以下结构:

第一部分是电影的数字标识符。它由冒号(::)分隔符后跟电影标题。接下来是括号中的电影年份。最后,在下一个冒号分隔符之后,我们有与电影匹配的流派。如果我们有多个流派,它们由竖线(|)分隔。
6.2 分割字符串
当处理数据时,你经常会面临挑战,因为在使用它进行分析之前,必须先对其进行预处理。最基本的预处理类型是分割包含多个信息片段的字符串。这是你将在本节中学到的技能。
对于每部电影,我们将从该字符串中提取电影的年份及其流派列表。然而,在我们对所有字符串这样做之前,我会向你展示如何对列表中的第一个字符串进行操作。我们首先提取它:
julia> movie1 = first(movies)
"0002844::Fantômas - À l'ombre de la guillotine (1913)::Crime|Drama"
注意,我们使用了第一个函数来获取 movies 向量的第一个元素。我们将使用的第一个与字符串一起工作的函数是 split。它接受两个参数:要分割的字符串和用于分割字符串的分隔符。默认情况下,分隔符是空白字符,但在这个例子中,我们首先想使用::。让我们尝试使用 split 函数:
julia> movie1_parts = split(movie1, "::")
3-element Vector{SubString{String}}:
"0002844"
"Fantômas - À l'ombre de la guillotine (1913)"
"Crime|Drama"
movie1_parts 变量现在包含三个字符串的向量,正如预期的那样。
你可能已经注意到,movies 向量具有 Vector{String}类型,而 movie1_parts 向量具有 Vector{SubString{String}}类型。这是因为 Julia 为了效率,在 split 函数分割字符串时,不会复制字符串,而是创建一个指向原始字符串切片的 SubString{String}对象。这种行为是安全的,因为 Julia 中的字符串是不可变的(我们已经在第四章中讨论了可变和不可变类型)。因此,一旦字符串被创建,其内容就不能被更改。创建字符串的子字符串是一个保证安全的操作。在你的代码中,如果你想创建一个 SubString{String},你可以使用 view 函数或 String 上的@view 宏。
由于 String 和 SubString{String}都是字符串,因此 Julia 中必须有一个更一般的字符串抽象概念。确实如此:
julia> supertype(String)
AbstractString
julia> supertype(SubString{String})
AbstractString
我们遇到的所有字符串类型都是 AbstractString 的子类型。在 Julia 中,AbstractString 是表示所有字符串的类型(在本章中,我们很快将讨论这个类型的更多子类型)。
应该在什么情况下使用 AbstractString?
当注释函数参数的类型应为字符串时,使用 AbstractString 而不是 String(除非你确实需要 String 类型,这种情况很少)。
例如,这是一个定义函数的好风格:
suffix_bang(s::AbstractString) = s * "!"
在这个定义中使用 String 而不是 AbstractString 是不推荐的,因为这样这个函数就不会与 SubString{String}参数一起工作。
split 函数是 Base Julia 中用于处理字符串的许多函数之一。你可以在 Julia 手册的“字符串”部分找到它们的文档(docs.julialang.org/en/v1/base/strings/)。以下是一些常用的函数:
-
string—通过使用 print 函数将传递的值转换为字符串
-
join—将迭代器的元素连接成一个字符串,在连接的项之间插入给定的分隔符
-
occursin—检查第一个参数是否是第二个参数的子字符串
-
contains—检查第二个参数是否是第一个参数的子字符串
-
replace—在给定的字符串中查找传递的模式,并用指定的值替换它们
-
strip—从字符串中删除前导和尾随字符(默认为空白字符)(相关的是 lstrip 和 rstrip,用于删除前导和尾随字符)
-
startswith—检查给定的字符串是否以传递的前缀开始
-
endswith—检查给定的字符串是否以传递的后缀结束
-
uppercase—将字符串转换为大写
-
lowercase—将字符串转换为小写
-
randstring—创建随机字符串(在 Random 模块中定义)
6.3 使用正则表达式处理字符串
在上一节中,你学习了如何通过使用 split 函数从以固定字符序列分隔的数据中提取信息。现在我们继续讨论如何使用正则表达式来提取字符串中遵循更通用模式的片段。
一旦我们创建了 movie1_parts 变量,我们可以将其第二个元素分割成电影名称和年份:
julia> movie1_parts[2]
"Fantômas - À l'ombre de la guillotine (1913)"
我们将通过使用正则表达式(www.regular-expressions.info)来完成这项任务。
6.3.1 使用正则表达式
如何编写正则表达式的主题非常广泛;如果你想要了解更多关于它的信息,我建议阅读 Jeffrey E. F. Friedl 的《精通正则表达式》(O’Reilly,2006)。Julia 支持 Perl 兼容的正则表达式,由 Perl-Compatible Regular Expressions (PCRE)库提供(www.pcre.org)。在这里,我将向你展示我们将使用的正则表达式以及如何在 Julia 中编写正则表达式字面量:
julia> rx = r"(.+) \((\d{4})\)$"
r"(.+) \((\d{4})\)$"
要创建一个正则表达式字面量,在字符串字面量前加上字母 r。这个正则表达式的含义在图 6.2 中解释。它最重要的部分是我们通过使用括号创建了两个捕获组。一个捕获组是一种从与正则表达式匹配的字符串中检索部分内容的方法。在我们的例子中,我们设计了两个捕获组:第一个将包含电影名称,第二个将包含电影年份。

图 6.2 r"(.+) ((\d{4}))" 正则表达式的解释
你可以通过使用 match 函数在 Julia 中轻松地将正则表达式与字符串匹配:
julia> m = match(rx, movie1_parts[2])
RegexMatch("Fantômas - À l'ombre de la guillotine (1913)",
1="Fantômas - À l'ombre de la guillotine", 2="1913")
m 变量绑定到表示将 rx 正则表达式与 movie1_parts[2]字符串匹配的结果的对象。当对象被显示时,我们可以看到它已经捕获了两组,正如预期的那样。这些组可以通过使用索引从 m 对象中轻松检索:
julia> m[1]
"Fantômas - À l'ombre de la guillotine"
julia> m[2]
"1913"
这种方法非常方便。如果我们想将年份存储为数字,我们应该使用我们在第五章中讨论过的 parse 函数来解析它:
julia> parse(Int, m[2])
1913
这是对 Julia 中正则表达式的一个简要教程。如果你想要了解更多关于如何使用它们的信息,我建议你阅读 Julia 手册中的整个“正则表达式”部分(mng.bz/WMBw)。
6.3.2 编写 movies.dat 文件单行内容的解析器
我们现在有了所有编写 movies.dat 文件单行解析器的部件。我建议将这个解析器定义为函数。接下来的列表显示了如何定义它。
列表 6.3 解析 movies.dat 文件单行内容的函数
function parseline(line::AbstractString)
parts = split(line, "::")
m = match(r"(.+) \((\d{4})\)", parts[2])
return (id=parts[1],
name=m[1],
year=parse(Int, m[2]),
genres=split(parts[3], "|"))
end
parseline 函数从我们的文件中取一行,并返回一个包含电影 ID、名称、年份和一系列类型的 NamedTuple。你可以在 6.1.2 和 6.1.3 节中找到对行解析所有部分的解释。我只想评论一下,表达式 split(parts[3], "|")) 取得 parts 向量的第三个元素,它包含由管道(|)分隔的类型列表,然后再次分割它。
让我们看看这个函数是如何处理我们文件的第一行的:
julia> record1 = parseline(movie1)
(id = "0002844", name = "Fantômas - À l'ombre de la guillotine",
year = 1913, genres = SubString{String}["Crime", "Drama"])
获得的结果是正确的,符合我们的预期。例如,要获取第一部电影的名字字符串,我们可以写 record1.name。
6.4 使用索引从字符串中提取子集
在我们继续分析 movies.dat 文件之前,让我们暂停一下,讨论一下在 Julia 中字符串是如何索引的。字符串索引通常用于提取字符串的一个子集。
6.4.1 Julia 中字符串的 UTF-8 编码
要理解字符串索引,你必须了解 UTF-8 编码的基础 (mng.bz/49jD)。
UTF-8 是一个描述字符串中单个字符如何用字节表示的标准。它的特殊之处在于不同的字符可以使用 1、2、3 或 4 个字节。这个标准是目前最常用的 (mng.bz/QnWR),特别是在 Julia 中。你可以使用 codeunits 函数来检查给定字符串的字节序列。以下列表展示了由一个字符组成但具有不同字节数的字符串的例子。
列表 6.4 具有不同字节长度的单字符字符串的 UTF-8 编码
julia> codeunits("a")
1-element Base.CodeUnits{UInt8, String}:
0x61
julia> codeunits("ε")
2-element Base.CodeUnits{UInt8, String}:
0xce
0xb5
julia> codeunits("∀")
3-element Base.CodeUnits{UInt8, String}:
0xe2
0x88
0x80
为了了解单个字符可能占用不同数量字节的影响,让我们调查一下我们在 6.3.2 节中创建的 record1.name 字符串。为了减少分析中的输出,我们将它限制在这个字符串的第一个单词上,即 Fantômas。我们看到它由八个字符组成,因此我们将通过使用第一个函数从我们的字符串中提取这些字符:
julia> word = first(record1.name, 8)
"Fantômas"
在这种情况下,第一个函数接受两个参数:一个字符串和从其前面取出的字符数——在我们的例子中是八个。你可能想知道我们是否可以将字符串视为字符集合,并通过使用索引提取它们。让我们试试:
julia> record1.name[1:8]
"Fantôma"
6.4.2 字符串的字符索引与字节索引
代码运行正常,但产生了意外的结果。由于某种原因,Julia 从名字中删除了最后一个字母。为什么?问题在于 Julia 中的字符串索引使用的是字节偏移量,而不是字符偏移量,而在 UTF-8 中,字母 ô 使用了 2 个字节进行编码。我们可以通过使用第五章中学到的 eachindex 函数来检查这一点。
julia> for i in eachindex(word)
println(i, ": ", word[i])
end
1: F
2: a
3: n
4: t
5: ô
7: m
8: a
9: s
或者通过在单个字母 ô 的字符串上使用 codeunits 函数:
julia> codeunits("ô")
2-element Base.CodeUnits{UInt8, String}:
0xc3
0xb4
让我们看看 Fantômas 字符串包含的字节单元:
julia> codeunits("Fantômas")
9-element Base.CodeUnits{UInt8, String}:
0x46
0x61
0x6e
0x74
0xc3
0xb4
0x6d
0x61
0x73
事实上,我们看到 ô 的字节索引等于 5,但下一个字母 m 的字节索引等于 7,因为 ô 使用了 2 个字节进行编码。在图 6.3 中,你可以看到 Fantômas 字符串中字符、字节(代码单元)、字节索引和字符索引的映射。

图 6.3 Fantômas 字符串中字符、字节(代码单元)、字节索引和字符索引的映射
字符串索引的这一行为可能一开始会相当令人惊讶。这种行为的理由是,根据上下文,你可能想要对你的字符串执行字节索引或字符索引,Julia 提供了这两种选项。通常,当你需要解析非标准输入数据(例如,来自物联网传感器的数据)时,你需要与字节一起工作,当你处理标准文本时,你需要与字符一起工作。
因此,在使用函数时,你必须始终检查它是否使用字节索引或字符索引。你已经看到,使用方括号进行索引使用字节索引,而函数首先使用字符计数。在我的博客文章“字符串,或者来来回回”(mng.bz/XaW1)中,我创建了一个在处理字符串时最常用函数的词汇表,包括它们使用的索引类型。
使用字符计数处理字符串
在数据科学工作流程中,你通常希望使用字符计数而不是字节索引来操作字符串。因此,建议你不要使用方括号索引字符串。
对于匹配复杂的模式,请使用正则表达式。对于更简单的场景,以下是一个列表,列出了最有用的函数,这些函数使用字符计数来处理字符串,并附有示例用法:
-
length("abc")—返回字符串中的字符数;产生 3。
-
chop("abcd", head=1, tail=2)—从字符串的头部或尾部移除指定数量的字符。在这种情况下,我们从头部移除一个字符,从尾部移除两个字符,产生 "b"。
-
first("abc", 2)—返回字符串中的前两个字符组成的字符串;产生 "ab"。
-
last("abc", 2)—返回字符串中的最后两个字符组成的字符串;产生 "bc"。
6.4.3 ASCII 字符串
在一种情况下,字节索引和字符索引保证会产生相同的结果。这发生在你的字符串只包含 ASCII 字符时。这类字符的最重要例子是数字 0 到 9,小写字母 a 到 z,大写字母 A 到 Z,以及常见的符号如 !, +, -, *, ), 和 (. 通常,任何可以在标准 US 键盘上不使用元键就能键入的字符都是 ASCII 字符。
ASCII 字符的一个重要特性是它们在 UTF-8 编码中总是由单个字节表示。在 Julia 中,你可以通过使用 isascii 函数轻松检查你的字符串是否只包含 ASCII 字符:
julia> isascii("Hello world!")
true
julia> isascii("∀ x: x≥0")
false
在第一种情况下,Hello world!字符串仅由字母、一个空格和一个感叹号组成,这些都是 ASCII 字符。在第二个例子中,∀和≥字符不是 ASCII 字符。
6.4.4 Char 类型
在我结束关于索引的讨论之前,让我简要地提一下,当你使用索引从字符串中选取单个字符时,你不会得到一个单字符字符串,就像在 R 或 Python 中那样,而是一个单独的字符类型,称为 Char。以下是一个示例:
julia> word[1]
'F': ASCII/Unicode U+0046 (category Lu: Letter, uppercase)
julia> word[5]
'ô': Unicode U+00F4 (category Ll: Letter, lowercase)
在这本书中,我们不需要处理单个字符,所以我省略了如何使用它们的所有细节。然而,如果你做很多自然语言处理,我建议阅读 Julia 手册中的“Characters”部分(mng.bz/820B)。
6.5 在 movies.dat 中分析流派频率
我们现在可以分析 movies.dat 文件中的电影流派了。通过这样做,你将学习如何创建频率表,频率表通常用于总结数据。
回想一下,我们想要执行两个任务:找出最常见的电影流派,并了解一个流派相对于电影年份的相对频率是如何变化的。
6.5.1 查找常见的电影流派
我们从第 6.2 节中定义的 movies 变量开始,并使用第 6.3 节中定义的 parseline 函数处理这个向量:
julia> records = parseline.(movies)
3096-element Vector{NamedTuple{(:id, :name, :year, :genres),
Tuple{SubString{String}, SubString{String}, Int64,
Vector{SubString{String}}}}}:
(id = "0002844", name = "Fantômas - À l'ombre de la guillotine",
year = 1913, genres = ["Crime", "Drama"])
(id = "0007264", name = "The Rink", year = 1916,
genres = ["Comedy", "Short"])
(id = "0008133", name = "The Immigrant", year = 1917,
genres = ["Short", "Comedy", "Drama", "Romance"])
(id = "0012349", name = "The Kid", year = 1921,
genres = ["Comedy", "Drama", "Family"])
⋮
(id = "2748368", name = "Neil", year = 2013, genres = ["Short", "Comedy"])
(id = "2750600", name = "A Different Tree", year = 2013,
genres = ["Short", "Drama", "Family"])
(id = "2763252", name = "Broken Night", year = 2013,
genres = ["Short", "Drama"])
(id = "2769592", name = "Kiss Shot Truth or Dare", year = 2013,
genres = ["Short"])
在 parseline 函数后添加一个点(.),这意味着我们将它广播到 movies 集合的所有元素上。结果,我们得到一个描述我们想要分析的电影的命名元组的向量。
让我们先找出在我们的数据集中哪种流派最频繁。我们将分两步完成这项任务:
-
创建一个包含我们分析的所有电影流派的单个向量。
-
使用 FreqTables.jl 包中的 freqtable 函数创建此向量的频率表。
第一步是创建一个包含所有电影流派的单个向量。我们可以以多种方式完成这项任务。在这里,我们将使用 append!函数,该函数将一个向量附加到另一个向量上。我们的代码将从可以存储字符串的空向量开始,并连续将其包含所有电影流派向量的向量附加到它上。以下是代码:
julia> genres = String[]
String[]
julia> for record in records
append!(genres, record.genres)
end
julia> genres
8121-element Vector{String}:
"Crime"
"Drama"
"Comedy"
"Short"
⋮
"Family"
"Short"
"Drama"
"Short"
append!函数接受两个参数。第一个是我们想要附加数据的向量,第二个是包含要附加数据的向量。
注意代码中的一个重要细节。genres 变量是一个存储 String 值的向量。另一方面,正如我们已经讨论过的,record.genres 是一个包含 SubString{String}值的集合。当你执行 append!操作时,SubString{String}值会自动转换为 String。这会导致在内存中分配新的字符串(回想一下,在 split 函数中使用 SubString{String}的目的就是为了避免这种分配)。由于我们的数据量较小,我决定这不是问题,因为这种方法造成的额外执行时间和内存消耗在这个情况下是可以忽略不计的。
现在我们准备创建一个频率表。我们将分三步完成这项任务:
-
加载 FreqTables.jl 包。
-
使用 freqtable 函数创建频率表。
-
使用 sort! 函数就地排序结果,以找到最频繁和最不频繁的类型。
这里是执行此任务的代码:
julia> using FreqTables
julia> table = freqtable(genres)
25-element Named Vector{Int64}
Dim1 │
────────────+────
│ 14
Action │ 635
Adventure │ 443
⋮ ⋮
Thriller │ 910
War │ 126
Western │ 35
julia> sort!(table)
25-element Named Vector{Int64}
Dim1 │
────────────+─────
News │ 4
Film-Noir │ 13
│ 14
⋮ ⋮
Thriller │ 910
Comedy │ 1001
Drama │ 1583
注意,freqtable 函数返回一个非标准数组,类型为 NamedVector。这种类型允许你使用命名索引。在我们的例子中,索引的名称是 genres。这种类型在 NamedArrays.jl 包中定义,你可以在 github.com/davidavdav/NamedArrays.jl 找到更多关于如何使用它的信息。在这里,让我只提一下,你可以通过使用 names 函数来获取索引的名称,并且对这样的数组进行排序是在值上排序(而不是在索引上)。
6.5.2 理解类型流行趋势随年份的变化
我们已经了解到戏剧是最常见的类型。我们现在准备找出这个类型作为电影年份函数的频率。我们将按以下步骤进行此分析:
-
将每部电影的年份提取到一个向量中。
-
对于每部电影,检查它是否包含戏剧作为其类型之一。
-
按年份创建戏剧在电影类型中出现的比例频率表。
这里是完成此任务的代码:
julia> years = [record.year for record in records]
3096-element Vector{Int64}:
1913
1916
1917
1921
⋮
2013
2013
2013
2013
julia> has_drama = ["Drama" in record.genres for record in records]
3096-element Vector{Bool}:
1
0
1
1
⋮
0
1
1
0
julia> drama_prop = proptable(years, has_drama; margins=1)
93×2 Named Matrix{Float64}
Dim1 ╲ Dim2 │ false true
────────────+───────────────────
1913 │ 0.0 1.0
1916 │ 1.0 0.0
1917 │ 0.0 1.0
⋮ ⋮ ⋮
2011 │ 0.484472 0.515528
2012 │ 0.577017 0.422983
2013 │ 0.623529 0.376471
在此代码中,为了创建年份和 has_drama 向量,我们使用列表推导。为了检查戏剧是否是其中一种类型,我们使用第五章中讨论的 in 操作符。最后,为了计算比例频率表,我们使用 FreqTables.jl 包中的 proptable 函数。我们传递 year 和 has_data 变量以创建交叉表,并通过传递 margins=1,我们要求计算第一维度的比例(即行)。在这种情况下,由于传递给 proptable 的第一个变量是 year,第二个是 has_drama,因此比例是按年份计算的。观察 proptable 自动按维度值对维度进行排序。
drama_prop 表很好,但不容易分析。让我们在下一个列表中创建一个年份与戏剧在电影类型中存在比例的图表。
列表 6.5 按年份绘制戏剧电影的比例
julia> using Plots
julia> plot(names(drama_prop, 1), drama_prop[:, 2]; legend=false,
xlabel="year", ylabel="Drama probability")
我们通过使用 names 函数从 drama_prop 矩阵的第一个轴提取年份。为了获取按年份的戏剧比例,我们使用 drama_prop[:, 2] 提取第二列。我们额外选择不显示图例,并为 x 轴和 y 轴创建标签。图 6.4 显示了结果。

图 6.4 在绘制戏剧类型按年份的比例时,没有明显的趋势可见。
如图 6.4 所示,似乎没有明显的趋势。因此,戏剧类型似乎在多年间保持稳定。然而,我们可以看到戏剧概率的变异性随着年份的推移而降低。这很可能是由于在最初几年中,电影很少。检查这一点是你的练习。
练习 6.1 使用年份变量创建电影数量的图表。
6.6 引入符号
在某些数据科学场景中,你希望使用字符串作为对象的标签或标记——例如,表示产品的颜色。你通常不希望操作这些标签;你在这上面进行的唯一操作是进行相等性比较,并且希望它非常快。
Julia 有一个特殊类型叫做 Symbol,它类似于字符串,具有这些特性。本节首先解释如何创建具有 Symbol 类型的值,然后讨论它们与字符串相比的优缺点。
6.6.1 创建符号
在我向你展示如何使用 Symbol 类型之前,你首先需要学习如何构建这些对象。你可以通过两种方式创建具有 Symbol 类型的值
第一种方法是调用 Symbol,传递给它任何值或值的序列。以下有三个例子:
julia> s1 = Symbol("x")
:x
julia> s2 = Symbol("hello world!")
Symbol("hello world!")
julia> s3 = Symbol("x", 1)
:x1
绑定到变量 s1、s2 和 s3 的所有三个值都具有 Symbol 类型:
julia> typeof(s1)
Symbol
julia> typeof(s2)
Symbol
julia> typeof(s3)
Symbol
在本例中要注意两个重要点。首先,当你向 Symbol 传递多个值,如 Symbol("x", 1)时,它们的字符串表示形式会被连接起来。
其次,更重要的是,你可以看到符号以两种方式显示。第一种风格是:x 和:x1,第二种风格更为冗长:Symbol("hello world!").
你可能会想知道什么规则支配着这一点。简短风格用于打印只包含构成有效变量名字符的 Symbol。在这个例子中,我们在 hello 和 world 之间使用了一个空格,由于在变量名中使用空格是不允许的,所以打印是以冗长形式进行的。
这里是同样规则的一个更多例子:
julia> Symbol("1")
Symbol("1")
由于 1 不是一个有效的变量名(它是一个整数字面量),所以我们得到一个以冗长形式打印的符号。
你可能已经猜到了创建符号的另一种样式。如果我们想要用作 Symbol 表示的字符序列是一个有效的变量名,那么我们可以在其前加上冒号(:)来创建一个 Symbol 值。因此,以下操作是有效的:
julia> :x
:x
julia> :x1
:x1
然而,记住如果字符序列不是一个有效的变量名,这种语法是不正确的。这里有一个例子:
julia> :hello world
ERROR: syntax: extra token "world" after end of expression
我们得到一个错误。这里是一个第二个例子:
julia> :1
1
这里,我们没有得到错误,但得到的不是 Symbol,而是一个整数 1。
6.6.2 使用符号
你知道如何创建一个具有 Symbol 类型的值。现在我们将专注于如何使用它们。
正如我提到的,Symbol 类型看起来与字符串相似,但它不是。我们可以通过测试其超类型来检查这一点:
julia> supertype(Symbol)
Any
在这里,我们看到类型是 Any,而不是 AbstractString。这意味着没有操作字符串的函数会与具有 Symbol 类型的值一起工作。在典型的数据科学工作流程中,实际上有用的符号操作只有相等性比较。因此,我们可以这样写:
julia> :x == :x
true
julia> :x == :y
false
重点是符号的相等比较非常快,比测试字符串的相等性快得多。下面的列表显示了一个简单的基准测试,其中我们在包含一百万个元素的向量中查找一个值。
列表 6.6 比较使用 String 与 Symbol 的性能
julia> using BenchmarkTools
julia> str = string.("x", 1:10⁶) ❶
1000000-element Vector{String}:
"x1"
"x2"
"x3"
⋮
"x999998"
"x999999"
"x1000000"
julia> symb = Symbol.(str) ❷
1000000-element Vector{Symbol}:
:x1
:x2
:x3
⋮
:x999998
:x999999
:x1000000
julia> @btime "x" in $str; ❸
5.078 ms (0 allocations: 0 bytes)
julia> @btime :x in $symb; ❹
433.000 μs (0 allocations: 0 bytes)
❶ 创建一个字符串值向量
❷ 创建一个符号值向量
❸ 测量在字符串值向量中查找值的性能
❹ 测量在符号值向量中查找值的性能
在这里,我们有两个向量:包含字符串的 str 和包含符号值的 symb。基准测试结果表明,在这种情况下,使用符号的查找速度比使用字符串快 10 倍以上。
你可能会问这是如何实现的。诀窍在于 Julia 内部维护一个全局符号池。如果你引入一个新的符号,Julia 首先检查它是否已经存在于这个池中,如果是,Julia 会重用它。因此,当你比较两个符号时,你可以比较它们在内存中的地址,而不需要检查它们的内容。
这种行为有两个额外的后果。一方面,定义许多相同的符号不会分配新的内存,因为它们将指向相同的引用值。另一方面,一旦符号分配到全局池中,它将一直保留到 Julia 会话结束,如果你创建了大量的唯一符号,这有时可能会在 Julia 程序中看起来像内存泄漏。
在你的代码中选择字符串和符号
作为一般建议,你应该在你的程序中优先使用字符串而不是符号。字符串更加灵活,并且有多个函数接受它们作为参数。然而,如果你需要在程序中进行大量类似字符串值的比较,但你预计不需要操作这些值并且需要最大性能,你可以考虑使用符号。
在我结束对符号的讨论之前,让我指出,在本节中,我专注于符号作为数据的使用。符号在 Julia 中的另一个应用是用于 元编程——对 Julia 代码的程序性操作。我们在这本书中不涉及这个高级主题,但如果你想了解更多,我推荐从 Julia 手册中的“元编程”部分开始学习(mng.bz/E0Dj)。
6.7 使用固定宽度字符串类型来提高性能
在许多数据科学工作流程中,我们处理仅由几个字符组成的字符串。想想美国各州的代码,由两个字母组成,或者标准的美国 ZIP 代码,由五个数字组成。如果你恰好处理这样的字符串,Julia 提供了比标准 String 或 Symbol 更高效的存储格式。
6.7.1 可用的固定宽度字符串
这些高级字符串类型在 InlineStrings.jl 包中定义。就像标准 String 类型一样,这些字符串类型是 UTF-8 编码的,但它们在两个方面与标准 String 类型不同:
-
作为好处,它们与数字一样易于处理(技术上,它们不需要在内存中动态分配)。
-
作为限制,它们在字节上有固定的最大大小。
InlineStrings.jl 包提供了八个固定宽度字符串类型:
-
String1—大小最多 1 字节
-
String3—大小最多 3 字节
-
String7—大小最多 7 字节
-
String15—大小最多 15 字节
-
String31—大小最多 31 字节
-
String63—大小最多 63 字节
-
String127—大小最多 127 字节
-
String255—大小最多 255 字节
在实际应用中,如果你想使用这些字符串,你可以手动选择适当类型,但通常建议自动执行类型选择。如果你在字符串上调用 InlineString 函数,它将被转换为最窄的匹配固定宽度字符串。同样,如果你在字符串集合上调用 inlinestrings 函数,将自动选择所有传递字符串的适当最窄公共类型。以下是一些这些函数工作的示例:
julia> using InlineStrings
julia> s1 = InlineString("x")
"x"
julia> typeof(s1)
String1
julia> s2 = InlineString("∀")
"∀"
julia> typeof(s2)
String3
julia> sv = inlinestrings(["The", "quick", "brown", "fox", "jumps",
"over", "the", "lazy", "dog"])
9-element Vector{String7}:
"The"
"quick"
"brown"
"fox"
"jumps"
"over"
"the"
"lazy"
"dog"
在这个例子中,我们可以看到“x”字符串可以编码为 String1,因为 x 字符在 UTF-8 编码中用 1 个字节表示。另一方面,∀字符,如你在列表 6.4 中看到的,在 UTF-8 中用 3 个字节表示,所以“∀”被转换为 String3。在 sv 变量的最后一个例子中,我们有几个字符串,但没有一个超过 7 个字节,而有些字符串长度超过 3 个字节。因此,作为操作的结果,我们得到一个 String7 值的向量。
6.7.2 固定宽度字符串的性能
为了展示使用在 InlineStrings.jl 包中定义的字符串类型的潜在好处,让我们进行一个简单的实验。我们想要生成两种字符串向量的字符串:一种使用 String 类型,另一种使用固定宽度字符串。
然后,我们将执行两个检查。在第一个检查中,我们将看到存储在两个向量中的对象需要多少内存。在第二个检查中,我们将基准测试 Julia 对这些向量进行排序的速度。我们从设置下一列表中的数据开始。
列表 6.7 设置不同字符串类型性能比较的数据
julia> using Random
julia> using BenchmarkTools
julia> Random.seed!(1234); ❶
julia> s1 = [randstring(3) for i in 1:10⁶] ❷
1000000-element Vector{String}:
"KYD"
"tLO"
"xnU"
⋮
"Tt6"
"19y"
"GQ7"
julia> s2 = inlinestrings(s1) ❸
1000000-element Vector{String3}:
"KYD"
"tLO"
"xnU"
⋮
"Tt6"
"19y"
"GQ7"
❶ 设置随机数生成器种子以确保示例的可重复性
❷ 生成 String 类型的随机字符串向量
❸ 将向量转换为具有 String3 类型的值向量
我们首先加载所需的包 Random 和 BenchmarkTools。接下来,我们使用 Random.seed!(1234)命令设置 Julia 随机数生成器的种子。我执行这一步是为了确保,如果你使用的是编写本书时使用的相同版本的 Julia,你将得到与列表 6.7 中所示相同的数据。
然后我们使用列表推导和 randstring 函数生成一个包含一百万个随机 String 类型字符串的向量。我们使用 randstring(3)调用确保我们的字符串由三个字符组成。最后,使用 inlinestrings 函数创建一个包含 String3 字符串的向量并将其绑定到 s2 变量。由于我们的所有字符串都由三个 ASCII 字符组成,String3 类型会自动被 inlinestrings 函数检测到。
我们的测试是比较存储在向量 s1 和 s2 中的所有对象使用的内存量(以字节为单位),我们使用 Base.summarysize 函数:
julia> Base.summarysize(s1)
19000040
julia> Base.summarysize(s2)
4000040
在这种情况下,s2 向量使用的内存少于 s1 向量使用的 25%,因为我们的字符串较短且长度一致。
第二个测试检查了排序 s1 和 s2 向量时的性能:
julia> @btime sort($s1);
227.507 ms (4 allocations: 11.44 MiB)
julia> @btime sort($s2);
6.019 ms (6 allocations: 7.65 MiB)
在这种情况下,我们可以看到对 s2 进行排序的速度大约比 s1 快 40 倍。
在本书的第二部分,你将了解到当从 CSV 文件获取数据时,Julia CSV 读取器可以自动检测使用固定宽度字符串而不是标准 String 类型是有用的。因此,在实践中,通常只需要意识到固定宽度字符串的存在和含义,这样当你在一个数据框中看到它们时,你不会对遇到由 String3 字符串组成的列感到惊讶。
练习 6.2 使用列表 6.7 中的 s1 向量,创建包含与 s1 向量中相同字符串的符号的 s3 向量。然后,基准测试你能够多快地对 s3 向量进行排序。最后,使用 unique 函数基准测试你能够多快地对 s1、s2 和 s3 向量进行去重。
6.8 使用 PooledArrays.jl 压缩字符串向量
本章我们将讨论的与字符串存储效率相关的最后一个场景是字符串向量的压缩。压缩用于在向量中包含相对较少的独特值相对于存储在向量中的元素数量时节省内存。
考虑以下场景。1936 年,英国统计学家和生物学家罗纳德·费希尔研究了三种鸢尾花:Iris setosa、Iris virginica和Iris versicolor。如果你学习过机器学习模型,你可能听说过这个实验;如果没有,你可以在archive.ics.uci.edu/ml/datasets/iris找到这个数据集的引用。
我们不会在本书中分析这个数据集,因为它在许多其他资源中都有涵盖,包括 Nina Zumel 和 John Mount 合著的《实用数据科学 R》(Manning, 2019)。然而,我将使用花名来展示字符串压缩的潜在好处。作为一项额外的技能,你将学习如何将数据写入文件。在我们创建文件后,我们将将其作为字符串向量读回。接下来,我们将压缩这个向量,并比较未压缩和压缩数据的内存占用。
6.8.1 创建包含花名的文件
我们将首先创建包含花名的文件。然后,我们将这些数据读回到一个 String 值的 Vector 和一个这样的值的 PooledArray 中,以比较它们占用的内存量。作为一项额外的技能,你将学习如何在 Julia 中将数据写入文本文件。
这里是写入三百万行数据的代码,通过在文件中重复写入Iris setosa、Iris virginica和Iris versicolor的名称。我们存储数据的文件称为 iris.txt。
列表 6.8 将 Iris 花名写入文件
julia> open("iris.txt", "w") do io
for i in 1:10⁶
println(io, "Iris setosa")
println(io, "Iris virginica")
println(io, "Iris versicolor")
end
end
我们首先使用 open 函数以写入模式打开 iris.txt 文件。我们通过传递 w 作为第二个位置参数来表示我们想要写入文件。注意,我们使用了你在第二章中学到的 do-end 块记法。在这个记法中,io 是绑定打开的文件描述符的变量的名称。然后,在 do-end 块内部,你可以将数据写入你的文件。使用 do-end 块的关键价值在于,文件描述符在操作完成后保证被关闭(即使 do-end 块内部抛出异常)。
在这种情况下,我们使用 println 函数将数据写入文件;第一个参数传递的是我们想要写入的文件描述符,第二个参数是我们想要写入的数据。println 函数在写入数据到文件后会插入一个换行符。如果我们想要避免换行符,我们可以使用 print 函数代替。
在我们继续之前,让我们检查文件是否确实已经创建:
julia> isfile("iris.txt")
true
6.8.2 将数据读入向量并压缩
我们已经创建了 iris.txt 文件;现在让我们使用本章中已经学过的 readlines 函数来读取它:
julia> uncompressed = readlines("iris.txt")
3000000-element Vector{String}:
"Iris setosa"
"Iris virginica"
"Iris versicolor"
⋮
"Iris setosa"
"Iris virginica"
"Iris versicolor"
现在通过使用来自 PooledArrays.jl 包的 PooledArray 构造函数来压缩这个向量:
julia> using PooledArrays
julia> compressed = PooledArray(uncompressed)
3000000-element PooledVector{String, UInt32, Vector{UInt32}}:
"Iris setosa"
"Iris virginica"
"Iris versicolor"
⋮
"Iris setosa"
"Iris virginica"
"Iris versicolor"
我们创建了一个具有 PooledVector 类型的向量。首先,让我们使用 Base.summarysize 函数来检查压缩向量确实比未压缩向量使用更少的内存:
julia> Base.summarysize(uncompressed)
88000040
julia> Base.summarysize(compressed)
12000600
注意,压缩对象的内存大小比未压缩对象小 85%。让我解释一下这种压缩是如何实现的。
6.8.3 理解 PooledArray 的内部设计
要理解为什么压缩向量可以使用比未压缩向量更少的内存,图 6.5 展示了 PooledVector{String, UInt32, Vector{UInt32}} 内部实现的最重要元素。

图 6.5 字符串的 PooledVector 包含一个整数引用集合和两个映射:一个是从字符串值到整数引用的映射,另一个是从整数引用到字符串值的映射。你可以通过编写 fieldnames(PooledArray) 来获取 PooledArray 的所有字段列表。
重要的是要注意,压缩池向量并不直接存储字符串。相反,它为存储的每个唯一字符串分配一个整数引用值。invpool 字典指示分配给每个唯一存储字符串的编号:
julia> compressed.invpool
Dict{String, UInt32} with 3 entries:
"Iris virginica" => 0x00000002
"Iris versicolor" => 0x00000003
"Iris setosa" => 0x00000001
在这种情况下,“Iris setosa”被分配为编号 1,“Iris virginica”为编号 2,“Iris versicolor”为编号 3。
注意,分配的编号从 1 开始。因此,通过使用向量很容易编码从数字到值的逆映射。这是在 pool 字段中实现的:
julia> compressed.pool
3-element Vector{String}:
"Iris setosa"
"Iris virginica"
"Iris versicolor"
现在,如果我们想在我们压缩的池向量中找到哪个字符串被分配了编号 1,我们只需取 compressed.pool 向量的第一个元素,在我们的例子中是 "Iris setosa"。
重要的是要注意 invpool 和 pool 中的映射是一致的。因此,如果我们取一个引用编号 i,以下不变量可以得到保证:compressed.invpool[compressed.pool[i]] 等于 i。
现在,你已经准备好理解压缩是如何实现的。请注意,分配给字符串的整数编号比字符串本身使用的内存少。因此,我们不是存储字符串,而是在 refs 向量中只存储分配给它们的编号。接下来,如果用户想要获取压缩向量的一个元素,则从 refs 向量中检索该元素的引用编号,然后在 pool 向量中查找实际值。因此,以下两行代码是等价的:
julia> compressed[10]
"Iris setosa"
julia> compressed.pool[compressed.refs[10]]
"Iris setosa"
在我们的例子中,分配给字符串的整数引用编号具有 UInt32 类型,因此它们使用 4 字节内存,正如第二章所述。另一方面,我们的字符串使用更多的内存,这我们可以再次使用 Base.summarysize 函数来检查,这次是广播到 compressed.pool 向量的元素:
julia> Base.summarysize.(compressed.pool)
3-element Vector{Int64}:
19
22
23
我们可以看到,这些字符串占用的内存比 4 字节多得多。此外,我们还需要考虑,除了未压缩向量中字符串的原始大小之外,我们还需要单独保留对这些向量的指针。考虑到这两个元素,你现在可以明白为什么压缩向量与未压缩向量相比,内存占用减少了七倍以上。
我花费了这么多时间解释池化向量的实现方式,因为这很重要,可以帮助你理解何时应该使用这种数据结构。如果你有一组字符串,其中唯一值的数量与原始集合的元素数量相比很少,使用池化向量将会是有益的。如果不是这种情况,你将不会从使用池化向量中受益,因为你不仅需要存储数据,还需要存储三个对象:refs、pool 和 invpool。重要的是要注意,pool 和 invpool 的大小与集合中唯一元素的数量成比例。因此,如果唯一值很少,它们会很小,但如果唯一值很多,它们会相当大。
总结一下,让我们使用在第五章中学到的字符串函数构建一个包含所有唯一值的向量,然后比较存储此类字符串的正常向量的尺寸与池化向量的尺寸:
julia> v1 = string.("x", 1:10⁶)
1000000-element Vector{String}:
"x1"
"x2"
"x3"
⋮
"x999998"
"x999999"
"x1000000"
julia> v2 = PooledArray(v1)
1000000-element PooledVector{String, UInt32, Vector{UInt32}}:
"x1"
"x2"
"x3"
⋮
"x999998"
"x999999"
"x1000000"
julia> Base.summarysize(v1)
22888936
julia> Base.summarysize(v2)
54152176
如预期的那样,由于 v1 和 v2 向量都包含所有唯一元素,压缩向量 v2 比未压缩的 v1 使用了超过两倍的内存。
与第 6.7 节中讨论的固定宽度字符串类似,在第二部分中,你将了解到当从 CSV 文件获取数据时,Julia CSV 读取器可以通过分析数据中存在的唯一值数量与存储元素数量的关系,自动检测使用压缩向量而不是标准向量是有用的。
如果你正在使用 Python 中的 pandas 并了解 Categorical 类型,或者你在 R 中使用过 factor,你可能会问它们与 PooledArrays.jl 有何关联。PooledArrays.jl 的目标是仅提供压缩。它不提供额外的逻辑,允许你处理分类值(在数据科学的意义上)。在第十三章中,你将学习到 CategoricalArrays.jl 包,它为 Julia 提供了一个精心设计的分类值实现。(如果你想要快速比较 PooledArrays.jl 和 CategoricalArrays.jl,我建议阅读我的“分类值与池化数组”博客文章,链接为 mng.bz/N547。)
6.9 选择字符串集合的适当存储
在这个阶段,你可能会被 Julia 提供的用于存储字符串集合的选项数量所淹没。幸运的是,正如我在第 6.7 节和第 6.8 节中已经暗示的那样,在读取例如 CSV 文件时,读取器会自动做出正确的选择。然而,如果你想要自己做出选择,总结一下可以指导你选择的规则是有用的:
-
如果你的字符串集合只有几个元素,或者你预计内存或性能对你的程序不是关键因素,你可以安全地只使用标准的 String 并将其存储在标准集合类型中(例如,一个 Vector)。
-
如果你有很多字符串需要存储,你有以下选择:
-
如果唯一值相对于你的集合元素数量的比例较小,你仍然可以使用 String,但需要将其存储在 PooledArrays.jl 包提供的 PooledArray 中。
-
否则,如果你的字符串较短且长度相似,你可以使用 InlineStrings.jl 包提供的固定宽度字符串(String1, String3, String7 等)并将它们存储在标准集合中(例如,一个 Vector)。
-
最后,如果你有很多具有许多唯一值的字符串,其中至少有一些相当长,你需要做出最终决定。如果你有兴趣将这些字符串作为标签处理,并且只想比较它们的相等性,使用 Symbol(记住,技术上符号不是字符串,但如果你只想比较它们,这很可能不会是问题)。否则,使用标准的 String 类型。在这两种情况下,你都可以使用标准集合(例如,一个 Vector)。
-
作为总结,我将引用计算机科学家唐纳德·克努特的话:“过早优化是所有罪恶的根源。”这与我们的主题有何关系?通常,当我开始编写 Julia 代码时,我通常最常使用 Vector{String} 来存储我的字符串(除非像 CSV.jl 这样的包自动做出最优决策,那么我可以免费获得优化)。接下来,我会检查是否遇到任何性能或内存瓶颈(最简单的方法是使用 @time 宏;更高级的剖析技术在第 1 版 Julia 手册的 docs.julialang.org/en/v1/manual/profile/ 中描述),如果有,就适当地调整所使用的数据类型。
Julia 中多分派的一个极其方便的特性,在第三章中讨论过,就是改变数据类型不会要求你重写代码的其他部分。只要你没有硬编码具体的数据类型,而是正确地使用了抽象类型,比如 AbstractString,Julia 将会自动处理你决定执行的字符串容器的具体实现的变化。
摘要
-
在 Julia 中,你可以使用来自 Downloads 模块的 download 函数从网络上下载文件。这是一个在实践中你将经常需要执行的操作。
-
你可以通过使用 * 符号来连接字符串,所以 "a" * "b" 会产生 "ab"。这在需要给字符串添加前缀或后缀时非常有用。
-
使用 $ 符号,你可以将值插入到字符串中。如果你有一个变量 x 绑定了值为 10,并且你输入 "x = $x",你会得到 "x = 10"。这种功能在实践中的应用很常见,例如,用于显示计算的中途结果。
-
你可以使用原始字符串字面量来避免在字符串字面量中对 \ 和 $ 进行特殊处理。这在指定 Windows 中的路径时很有用。例如,raw"C:\DIR" 字面量;如果我们省略了原始前缀,我们会得到一个错误。
-
readlines 函数可以用来将文件的 内容读入一个字符串向量,其中结果向量的每个元素都是一个表示源文件单行的字符串。这种方式读取文件很方便,因为大多数文本数据最终都是按行解析的。
-
可以使用 split 函数根据指定的分隔符将字符串分割成多个字符串。例如,split("a,b", ",") 产生一个 ["a", "b"] 向量。这种对源数据的解析在实际应用中很常见。
-
Julia 中的标准字符串类型是 String。然而,由于 Julia 中的字符串是不可变的,当将一些标准函数应用于 String 类型的字符串时,它们会返回一个具有 SubString{String} 类型的视图。这种视图的好处是它们不需要分配额外的内存来存储。
-
当你编写接受字符串的自定义函数时,在函数定义中指定 AbstractString 作为类型参数,以确保它将适用于用户可能想要传递的任何字符串类型。由于 Julia 有许多字符串类型,因此你的函数应以通用方式实现。
-
Julia 完全支持使用正则表达式,这对于从字符串数据中提取信息很有用。你可以通过在字符串前加上 r 来创建正则表达式文字——例如,r"a.a" 模式匹配以 a 开头和结尾的三个字符序列,中间包含任何字符。正则表达式通常用于从文本源中提取数据。
-
可以使用 parse 函数将字符串转换为数字;例如,parse(Int, "10") 返回整数 10。在处理存储在文本文件中的数值数据时,这种功能通常很有用。
-
Julia 中的字符串使用 UTF-8 编码,因此每个字符在字符串中可能占用 1、2、3 或 4 个字节。在这种编码中,ASCII 字符始终占用 1 个字节。因此,通常情况下,字符串中的字符数可能少于该字符串的字节数。
-
在操作字符串时,你可以使用字节或字符计数来引用字符串的某个具体部分。对于 ASCII 字符串,这些方法等效,但在一般情况下并不等效。你必须始终检查你使用的函数是操作字节计数还是字符计数。
-
使用方括号进行字符串索引——例如,"abc"[2:3]——使用字节索引。由于 Julia 中的字符串是 UTF-8 编码的,因此并非所有索引都适用于这种索引方式。
-
常用的使用字符索引的函数有 length、chop、first 和 last。
-
FreqTables.jl 包提供了 freqtable 和 proptable 函数,允许你轻松地从你的数据中创建频率表。这些源数据摘要在数据科学工作流程中很常见。
-
符号是一个特殊类型,它不是字符串,但在你的字符串被视为标签且只需要比较它们时,有时会用到。同时,要求比较操作快速执行。
-
可以使用冒号(:)前缀方便地创建有效的变量名标识符,例如,:some_symbol。
-
InlineStrings.jl 包定义了几个固定宽度的字符串类型:String1, String3, String7 等。如果你的字符串较短且长度一致,这种非标准字符串类型将使用更少的内存,并且在许多操作(如排序)中处理速度更快。
-
你可以使用 open 函数打开文件进行写入。然后,你可以通过使用 println 函数(例如,如果你将文件描述符(指示数据应写入的位置)作为第一个参数传递)来写入它们。
-
来自 PooledArrays.jl 包的 PooledArray 类型值允许你在其中只有少量唯一值时压缩字符串集合的内存大小。在这种情况下,使用此功能可以节省大量 RAM。
-
Julia 提供了多种将字符串存储在集合中的选项,每种选项都有略微不同的性能和内存使用概况。这允许你根据处理的数据结构灵活优化你的代码。此外,当检测到使用这些字符串会更高效时,几个标准的 Julia 包,如 CSV.jl,会在读取数据时自动为你创建非标准的 InlineStrings.jl 字符串或 PooledVector。
7 处理时间序列数据和缺失值
本章涵盖
-
使用 HTTP 查询获取数据
-
解析 JSON 数据
-
处理日期
-
处理缺失值
-
绘制含有缺失值的图表
-
插值缺失值
这是第一部分的最后一章,重点关注 Julia 语言。本章将要涵盖的主题的一个激励用例是处理金融资产价格。想象一下,你想分析某只股票的价格或两种货币之间的汇率随时间的变化。为了能够在 Julia 中处理这些问题,你需要知道如何处理时间序列数据。现实生活中时间数据的常见特征是某些时间戳包含缺失数据。因此,本章的第二个主要主题是在 Julia 中处理缺失值。
本章我们解决的问题是分析波兰国家银行(NBP)发布的 PLN/USD 汇率。数据通过 Web API 提供,API 描述在api.nbp.pl/en.html。
我们将通过以下步骤完成任务:
-
理解 Web API 公开的数据格式。
-
使用 HTTP GET 请求获取指定日期范围内的数据。
-
处理请求的数据不可用时的错误。
-
从查询获取的结果中提取 PLN/USD 汇率。
-
对获取的数据进行简单的统计分析。
-
使用适当的处理方法绘制获取的图表数据。
为了执行这一系列步骤,你需要学习如何在 Julia 中处理缺失数据,处理日期,使用 HTTP 请求获取数据,以及解析使用 JSON 格式传递的信息(www.json.org/json-en.html)。为了帮助你逐一学习这些主题,本章分为四个部分:
-
在 7.1 节中,你将学习 NBP 通过其 Web API 公开的汇率数据的 JSON 格式;你将了解如何在 Julia 中执行 HTTP GET 请求以及如何解析 JSON 数据。
-
在 7.2 节中,你将学习如何在 Julia 中处理缺失值。这些知识是理解如何处理从 NBP Web API 获取的数据所必需的,因为其中包含缺失值。
-
在 7.3 节中,你将了解如何处理来自不同日期的 NBP Web API 查询数据,并将它们的结果作为时间序列进行处理。你将学习如何在 Julia 中处理日期。
-
在 7.4 节中,你将对时间序列数据进行统计分析并绘制图表。我们将特别关注在数据分析和可视化中处理缺失数据。
7.1 理解 NBP Web API
在你开始分析汇率数据之前,你需要学习如何从 NBP Web API 获取它。此外,正如你很快就会看到的,NBP Web API 以 JSON 格式公开汇率信息,因此你还将了解如何解析它。我选择这个数据源是因为 JSON 格式被许多数据源广泛使用,因此学习如何处理它是有价值的。此外,你可以预期在实际操作中,你通常需要在数据科学项目中通过各种 Web API 获取数据。
我们首先通过网页浏览器检查 NBP Web API 公开的数据。我们将通过网页浏览器传递示例查询。接下来,你将学习如何以编程方式执行此操作。
7.1.1 通过网页浏览器获取数据
Web API 的完整规范可在api.nbp.pl/en.html找到。API 可以通过网页浏览器和编程方式访问。我们首先使用网页浏览器查询它。就我们的目的而言,了解请求的一种格式就足够了:
https://api.nbp.pl/api/exchangerates/rates/a/usd/YYYY-MM-DD/?format=json
在这个请求中,你应该将 YYYY-MM-DD 部分替换为特定日期,首先输入年份的四位数字,然后是月份的两位数字,最后是日期的两位数字。以下是一个获取 2020 年 6 月 1 日数据的示例:
https://api.nbp.pl/api/exchangerates/rates/a/usd/2020-06-01/?format=json
当你在网页浏览器中运行此查询时,你应该得到以下响应(根据你使用的浏览器,响应的布局可能略有不同):
{
"table":"A",
"currency":"dolar amerykański",
"code":"USD",
"rates":[
{
"no":"105/A/NBP/2020",
"effectiveDate":"2020-06-01",
"mid":3.9680
}
]
}
结果以 JSON 格式返回。你可以在www.json.org/json-en.html找到格式规范。此外,如果你想了解更多关于 JSON 的信息,可以考虑阅读 iCode Academy(白花出版社,2017 年)的《JSON 入门:7 天内轻松学习 JSON 编程指南》或浏览 MDN Web 文档教程mng.bz/DDKa。在这里,我将专注于解释如何解释这个特定的 JSON 结构。
图 7.1 展示了说明。结果包含一个具有四个字段的对象:表格、货币、代码和汇率。对我们来说,有趣的字段是汇率,它包含一个包含单个对象的数组。这个单个对象有三个字段:no、effectiveDate 和 mid。对我们来说,重要的字段是 mid,它存储了我们查询的那天的 PLN/USD 汇率。对于 2020 年 6 月 1 日,汇率为 3.960 PLN/USD。

图 7.1 在对 NBP Web API 进行请求返回的 JSON 数据中,键值字段被大括号括起来,数组被方括号括起来。
7.1.2 使用 Julia 获取数据
现在我们已经了解了数据的结构,我们转向 Julia。我们将通过使用 HTTP.jl 包中的 HTTP.get 函数从 NBP Web API 获取数据。接下来,我们将使用 JSON3.jl 包中提供的 JSON 读取器函数 JSON3.read 来解析这个响应。下面的列表展示了如何执行这些步骤。
列表 7.1 执行 NBP Web API 查询和解析获取的 JSON 响应
julia> using HTTP
julia> using JSON3
julia> query = "https://api.nbp.pl/api/exchangerates/rates/a/usd/" *
"2020-06-01/?format=json" ❶
"https://api.nbp.pl/api/exchangerates/rates/a/usd/2020-06-01/?format=json"
julia> response = HTTP.get(query) ❷
HTTP.Messages.Response:
"""
HTTP/1.1 200 OK
Date: Mon, 06 Dec 2021 10:29:10 GMT
Cache-Control: no-cache
Pragma: no-cache
Content-Length: 134
Content-Type: application/json; charset=utf-8
Expires: -1
ETag: "cZimS9v8pROOFg78jX55w0AsnRyhsNg4/e6vNH+Nxos="
Set-Cookie: ee3la5eizeiY4Eix=ud5ahSho; path=/
{"table":"A","currency":"dolar amerykański","code":"USD",
"rates":[{"no":"105/A/NBP/2020",
"effectiveDate":"2020-06-01","mid":3.9680}]}"""
julia> json = JSON3.read(response.body) ❸
JSON3.Object{Vector{UInt8}, Vector{UInt64}} with 4 entries:
:table => "A"
:currency => "dolar amerykański"
:code => "USD"
:rates => JSON3.Object[{...
❶ 定义一个包含我们查询的 URL 的字符串
❷ 向 NBP Web API 发送 HTTP GET 请求
❸ 将获取的响应数据解析为 JSON
我们将查询字符串传递给 HTTP.get 函数并获取响应对象。我们可以在打印的结果消息中看到查询是成功的,因为它有一个 200 OK 状态码。在响应消息的底部,我们看到与我们在网页浏览器中获取的相同的 JSON 数据。
响应对象包含多个字段,对我们来说重要的是 body 字段,它存储了获取的字节向量:
julia> response.body
134-element Vector{UInt8}:
0x7b
0x22
0x74
⋮
0x7d
0x5d
0x7d
我们将这个字节向量传递给 JSON 读取器函数 JSON3.read。在讨论这一步之前,让我解释一下如何高效地检查 response.body 的内容作为字符串。你可以简单地使用 String 构造函数:
julia> String(response.body)
"{\"table\":\"A\",\"currency\":\"dolar amerykański\",\"code\":\"USD\",
\"rates\":[{\"no\":\"105/A/NBP/2020\",
\"effectiveDate\":\"2020-06-01\",\"mid\":3.9680}]}"
此操作是高效的,这意味着字符串被包裹在传递的字节向量周围,并且没有进行数据复制。这有一个我们必须注意的副作用。由于我们使用了字节向量来创建 String 对象,所以 response.body 向量被清空了:
julia> response.body
UInt8[]
在 Vector{UInt8}上调用 String 构造函数会消耗存储在向量中的数据。这种行为的优点是操作非常快。缺点是转换只能进行一次。操作完成后,response.body 向量是空的,因此再次调用 String(response.body)会产生一个空字符串(""):
String 构造函数清空传递给它的 Vector{UInt8}源是 Julia 中很少见的函数之一,当它修改的参数没有以!后缀命名时。因此,记住这个例外非常重要。在我们的例子中,如果你想保留 response.body 中存储的值,你应该在传递给 String 构造函数之前将其复制,如下所示:String(copy(response.body))。
现在你已经了解了如何处理响应对象,让我们转向 json 变量,它是我们绑定 JSON3.read(response.body)返回值的。JSON3.read 函数的一个优点是它返回的对象可以像 Julia 中的任何其他对象一样查询。因此,使用点(.)来访问其字段:
julia> json.table
"A"
julia> json.currency
"dolar amerykański"
julia> json.code
"USD"
julia> json.rates
1-element JSON3.Array{JSON3.Object, Vector{UInt8},
SubArray{UInt64, 1, Vector{UInt64}, Tuple{UnitRange{Int64}}, true}}:
{
"no": "105/A/NBP/2020",
"effectiveDate": "2020-06-01",
"mid": 3.968
}
类似地,JSON 数组,例如存储在 json.rates 字段中的数组,可以使用基于 1 的索引来访问,就像 Julia 中的任何向量一样。因此,要获取存储在 json.rates 中的第一个对象的 mid 字段,你可以编写如下代码:
julia> json.rates[1].mid
3.968
接下来,我将介绍一个有用的函数,它可以用来确保在 Julia 中从数组获取数据的一个特定用例的正确性。如果我们知道并想检查一个数组恰好包含一个元素,并且我们想提取它,我们可以使用唯一函数:
julia> only(json.rates).mid
3.968
唯一函数的一个重要属性是,如果我们的向量包含零个或多个元素,它将抛出一个错误:
julia> only([])
ERROR: ArgumentError: Collection is empty, must contain exactly 1 element
julia> only([1, 2])
ERROR: ArgumentError: Collection has multiple elements, must contain exactly 1 element
当编写生产代码时,唯一的功能非常有用,因为它允许你轻松地捕获数据不符合假设时出现的错误。
7.1.3 处理 NBP Web API 查询失败的情况
在我们继续获取更广泛日期范围的数据之前,让我们讨论 NBP Web API 的一个更多功能。我想考虑的场景是,如果我们没有给定日期的 PLN/USD 汇率数据会发生什么。首先,在你的浏览器中执行以下查询:
https://api.nbp.pl/api/exchangerates/rates/a/usd/2020-06-06/?format=json
你应该得到以下响应:
404 NotFound - Not Found - Brak danych
我们看到在这种情况下,数据没有 2020 年 6 月 6 日的日期。让我们看看当我们尝试在下一个列表中程序化执行查询时,这个场景是如何处理的。
列表 7.2 抛出异常的查询示例
julia> query = "https://api.nbp.pl/api/exchangerates/rates/a/usd/" *
"2020-06-06/?format=json"
"https://api.nbp.pl/api/exchangerates/rates/a/usd/2020-06-06/?format=json"
julia> response = HTTP.get(query)
ERROR: HTTP.ExceptionRequest.StatusError(404, "GET",
"/api/exchangerates/rates/a/usd/2020-06-06/?format=json",
HTTP.Messages.Response:
"""
HTTP/1.1 404 Not Found
Date: Mon, 06 Dec 2021 10:56:16 GMT
Cache-Control: no-cache
Pragma: no-cache
Content-Length: 38
Content-Type: text/plain; charset=utf-8
Expires: -1
Set-Cookie: ee3la5eizeiY4Eix=Naew5Ohp; path=/
404 NotFound - Not Found - Brak danych""")
HTTP.get 函数在这种情况下抛出一个异常,返回 404 错误,告知我们请求的页面未找到。这是我们尚未遇到的新场景。让我们讨论如何处理它。
异常意味着在执行查询时发生了意外情况,Julia 程序立即终止而没有产生结果。
这种行为在我们遇到意外情况时很有用。然而,在这种情况下,我们可以认为这种情况是预期的。我们不希望我们的程序在没有得到适当的查询结果时停止。或者,我们最可能想要得到一个结果,表明给定的一天没有数据,因为这一天缺少 PLN/USD 汇率。在 Julia 中,这样的值表示为 missing,7.2 节详细说明了它的含义和使用方法。
让我们讨论如何处理异常,以便它们不会在我们不希望它们终止程序的情况下终止我们的程序。为此,我们使用 try-catch-end 块。

图 7.2 使用 NBP Web API 处理 HTTP GET 错误的逻辑。当从远程位置获取数据时,错误经常发生,因此你的代码应该为这种情况做好准备。
列表 7.3 中的代码执行以下操作(也请参阅图 7.2):
-
尝试在块的 try 部分执行我们的查询
-
如果查询成功,则返回其结果
-
如果查询失败,则执行块的 catch 部分的代码
列表 7.3 使用 try-catch-end 块处理异常
julia> query = "https://api.nbp.pl/api/exchangerates/rates/a/usd/" *
"2020-06-01/?format=json"
"https://api.nbp.pl/api/exchangerates/rates/a/usd/2020-06-01/?format=json"
julia> try ❶
response = HTTP.get(query)
json = JSON3.read(response.body)
only(json.rates).mid ❷
catch e ❸
if e isa HTTP.ExceptionRequest.StatusError ❹
missing
else
rethrow(e) ❺
end
end
3.968
julia> query = "https://api.nbp.pl/api/exchangerates/rates/a/usd/" *
"2020-06-06/?format=json"
"https://api.nbp.pl/api/exchangerates/rates/a/usd/2020-06-06/?format=json"
julia> try
response = HTTP.get(query)
json = JSON3.read(response.body)
only(json.rates).mid
catch e
if e isa HTTP.ExceptionRequest.StatusError
missing
else
rethrow(e)
end
end
missing
❶ 在 try 部分,我们执行应该正常工作的代码。
❷ 从获取的请求响应中提取唯一条目(利率向量)的中场
❸ 如果我们尝试执行的代码抛出错误,将错误信息存储在变量 e 中
❹ 检查错误是否由 HTTP 服务器的状态错误引起;在这种情况下,产生一个缺失值
❺ 如果错误有其他原因,重新抛出它,以便程序员知道发生了意外情况
我们可以看到,对于有效的日期 2020 年 6 月 1 日,我们得到解析值 3.968,而对于无效日期 2020 年 6 月 6 日,则产生一个缺失值。
让我们在以下伪代码中回顾一下 try-catch-end 块的结构:
try
<instructions that we try to execute>
catch
<instructions executed if there is an exception in the try part>
end
在这里,我们正在尝试执行我们已经讨论过的三个操作:获取数据,以 JSON 格式解析它,并提取汇率。
现在看看列表 7.3 中的捕获部分。首先注意捕获关键字后面的 e。这种语法意味着我们将异常信息绑定到名为 e 的变量上。
接下来,我们使用 e isa HTTP.ExceptionRequest.StatusError 来检查异常是否确实是 HTTP 请求的问题。这正是我们在列表 7.2 中看到的异常类型。如果有 HTTP 请求的问题,我们产生缺失值。但是,我们只在这种情况下这样做。在其他所有情况下,我们使用 rethrow 函数重新抛出我们刚刚捕获的相同异常。
你可能会问我们为什么这样做。原因是我们可能会得到由例如唯一函数引发的异常。正如你所知道的那样,如果这个函数接收到一个没有恰好一个元素的数组,它将引发异常。如果这种情况发生,我们不想通过产生缺失值来隐藏这样的问题,而是更愿意明确警告用户发生了意外情况(我们期望一个元素数组,但得到了其他东西)。
不要盲目地在你的代码中捕获任何异常。
如果你在代码中捕获异常,一个好的做法是始终检查它们的类型,并且只捕获你想优雅处理的异常。有许多异常类型(例如,OutOfMemoryError),你几乎不希望默默地隐藏,并且它们可能在代码的任意部分抛出。例如,如果一个 Julia 程序耗尽内存,它很可能无法正确继续执行。
在本节中,我仅讨论了 try-catch-end 块的最简单用法。我在书中这么晚才讨论这个话题,因为虽然你需要知道如何处理异常,但它们应该保留用于异常情况。在正常情况下,更好的做法是以不抛出异常的方式编写代码。
这个建议基于两个原因。首先,处理异常相对较慢,因此大量使用 try-catch-end 块可能会降低其性能。其次,这样的代码通常更难推理。
如果你想要了解更多关于异常处理的信息,请查看 Julia 手册中的“异常处理”部分(mng.bz/lR9B)。
7.2 在 Julia 中处理缺失数据
在 7.1 节的例子中,我们决定如果 NBP Web API 中的 PLN/USD 汇率数据缺失,则产生缺失。在本节中,我们将定义缺失值并讨论为什么它在数据科学项目中使用。
学习如何处理缺失值非常重要,因为大多数现实生活中的数据都存在质量问题。在实践中最常见的处理情况之一是,你想要分析的对象的一些特征尚未收集。例如,想象你正在分析一家医院中患者的体温数据。你期望每小时进行一次测量。然而,有时测量并未进行或未记录。这些情况在你要分析的数据中表现为缺失。
7.2.1 缺失值的定义
让我们从 Julia 手册中缺失值的定义开始(docs.julialang.org/en/v1/manual/missing/):
Julia 提供了对表示统计意义上的缺失值的支持,即对于观察中某个变量的值不存在,但理论上存在有效值的情况。
这种情况表示为具有 Missing 类型的缺失值。Julia 语言(类似于 R,但与 Python 等不同)设计时内置了缺失值的概念。在你的代码中,你不需要使用哨兵值来表示数据的缺失。你可以通过使用 ismissing 函数轻松检查一个值是否缺失:
julia> ismissing(missing)
true
julia> ismissing(1)
false
让我们回顾一下第三章中引入的另一个值:无(类型为 Nothing)。无与缺失有何不同?你应该使用无来表示值的客观不存在,而缺失表示一个存在但未被记录的值。
为了确保缺失值和无之间的区别清晰,让我举一个非技术性的例子。假设我们有一个人的信息,我们询问他们的车品牌。我们可以有以下三种情况:
-
这个人有一辆车,我们知道品牌;然后我们只需给出它。
-
我们知道这个人有车,但我们不知道品牌;那么我们应该产生缺失,因为从客观上讲,这个品牌名称存在,但我们不知道它。
-
我们知道这个人没有车,我们产生无,因为该值在客观上是不存在的。
事实上,在某些情况下,使用缺失值和无之间的界限很微妙(如前例所示——如果我们甚至不知道这个人是否有车怎么办?)。然而,在实际应用中,根据应用上下文,决定使用缺失值还是无通常很容易。在数据科学领域,缺失值主要在源数据中遇到,当由于某种原因记录失败时。
7.2.2 处理缺失值
缺失的定义对原生支持它的编程语言的设计(如 Julia 或 R)有重要影响。在本节中,我们讨论了处理缺失值的最重要方面。
函数中的缺失值传播
原则是许多函数会静默地传播缺失值——也就是说,如果它们以缺失值作为输入,它们会以缺失值作为输出返回。以下是一些例子:
julia> 1 + missing
missing
julia> sin(missing)
missing
缺失传播的一个重要情况是在应该产生布尔值的测试上下文中:
julia> 1 == missing
missing
julia> 1 > missing
missing
julia> 1 < missing
missing
这种行为通常被称为 三值逻辑 (mng.bz/o59Z),因为你可以从逻辑运算中获得真、假或缺失。
三值逻辑是处理缺失值的一种逻辑上一致的方法。然而,在逻辑测试的上下文中,如果我们可能遇到缺失数据,我们应该小心。原因是将缺失值作为条件传递给条件语句会产生错误:
julia> if missing
print("this is not printed")
end
ERROR: TypeError: non-boolean (Missing) used in boolean context
julia> missing && true
ERROR: TypeError: non-boolean (Missing) used in boolean context
在 Julia 中处理缺失值的设计要求你明确决定缺失值应该被处理为真还是假。这是通过 coalesce 函数实现的,你可能从 SQL 中了解过它(mng.bz/BZ1r)。其定义很简单:coalesce 返回其第一个非缺失的位置参数,或者如果所有参数都是缺失的,则返回缺失。
coalesce 的使用最常见于处理逻辑条件。如果你写 coalesce(condition, true),这意味着如果条件评估为缺失,你希望这个缺失被视为真。同样,coalesce(condition, false) 表示你希望将缺失视为假。以下是一个例子:
julia> coalesce(missing, true)
true
julia> coalesce(missing, false)
false
逻辑条件中的缺失值
如果你的数据可能包含你可能在逻辑条件中使用的缺失值,请始终确保用 coalesce 包裹它们,第二个参数根据你希望如何处理条件中的缺失值来设置为真或假。
使用保证布尔结果的比较运算符
有时,然而,将缺失值在比较中像其他任何值一样处理,而不使用具有三值逻辑的特殊处理,是有用的。如果需要在你的代码中这样做,你可以使用 isless 函数来测试排序,使用 isequal 来测试相等。这两个函数保证无论传递给它们的值是什么,都会返回真或假。以下是一些例子:
julia> isequal(1, missing)
false
julia> isequal(missing, missing)
true
julia> isless(1, missing)
true
julia> isless(missing, missing)
false
作为一条特殊规则,在 isless 对缺失值与数字的比较中,缺失值始终被视为大于所有数字,所以我们有以下结果:
julia> isless(Inf, missing)
true
在 isequal 比较中,缺失值仅被视为等于自身。
除了保证返回布尔值的 isequal 函数之外,Julia 还提供了另一种比较值以返回布尔值的方法。这种比较是通过 === 运算符执行的。
isequal函数与===运算符的区别在于,对于支持相等概念的价值,isequal通常被实现为比较值本身,而===测试两个值在技术上是否相同(即没有程序可以区分它们)。比较内容与技术身份之间的区别通常在处理如向量之类的可变集合时最为明显。以下是一个示例:
julia> a = [1]
1-element Vector{Int64}:
1
julia> b = [1]
1-element Vector{Int64}:
1
julia> isequal(a, b)
true
julia> a === b
false
向量 a 和 b 具有相同的内容,因此isequal测试返回 true。然而,它们在技术上不同,因为它们有不同的内存位置。因此,===测试返回 false。你将在第二部分中看到更多使用===运算符的示例。最后,Julia 有!==运算符,它总是给出与===相反的答案。
===、==和isequal之间的关系
这里是控制===和==运算符以及isequal函数之间关系的规则:
-
===运算符始终返回一个 Bool 值,并允许我们比较任何值以确定其身份(即没有程序可以区分它们)。 -
==运算符默认回退到===。如果一个类型在逻辑上支持相等概念(如数字、字符串或数组),它为==运算符定义了一个特殊方法。例如,数字是根据它们的数值进行比较的,而数组是根据它们的内容进行比较的。 -
因此,为它们实现了
==运算符的特殊方法。当使用==运算符进行比较时,用户必须记住以下特殊规则:-
使用
==与缺失值进行比较始终返回缺失值。 -
使用浮点 NaN 值进行比较始终返回 false(有关此规则的更多示例,请参阅第二章)。
-
浮点正零(0.0)和负零(-0.0)的比较返回 true。
-
-
isequal函数的行为类似于==运算符,但总是返回一个 Bool 值,并且为其定义了不同的特殊规则:-
使用
isequal与缺失值进行比较返回 false,除非两个缺失值进行比较,此时返回 true。 -
与浮点 NaN 值进行比较返回 false,除非两个 NaN 值进行比较,此时返回 true。
-
浮点正零(0.0)和负零(-0.0)的比较返回 false。
-
isequal用于比较字典中的键。
重要的是要记住,当键值对存储在Dict字典中时,键的相等性是通过isequal函数确定的。例如,由于 0.0 与isequal比较时不等于-0.0,字典Dict(0.0 => "zero", -0.0 => "negative zero")存储了两个键值对,一个对应于 0.0 键,另一个对应于-0.0 键。
同样的规则适用于分组和连接数据帧(这些主题将在第二部分中讨论)。
在集合中替换缺失值
让我们回到 coalesce 函数的另一种常见用法,即缺失数据插补。假设你有一个包含缺失值的向量,如下所示。
列表 7.4 包含缺失值的向量
julia> x = [1, missing, 3, 4, missing]
5-element Vector{Union{Missing, Int64}}:
1
missing
3
4
missing
x 向量包含整数和缺失值,所以它的元素类型,如第三章所述,是 Union{Missing, Int64}。假设我们想用 0 替换所有缺失值。这可以通过广播 coalesce 函数轻松完成:
julia> coalesce.(x, 0)
5-element Vector{Int64}:
1
0
3
4
0
在计算中跳过缺失值
如果缺失值隐藏在集合中(如列表 7.4 中的向量 x),则有时也会不希望传播缺失值。例如,考虑 sum 函数:
julia> sum(x)
missing
结果在逻辑上是正确的。我们有想要添加的缺失值,所以结果是未知的。然而,我们可能非常常见地想要添加向量中的所有非缺失值。为此,使用 skipmissing 函数创建一个围绕 x 向量的包装器:
julia> y = skipmissing(x)
skipmissing(Union{Missing, Int64}[1, missing, 3, 4, missing])
现在 y 变量绑定到一个新对象,它在其内部存储 x 向量,但当你迭代 y 时,它会跳过存储在 x 中的缺失值。现在,如果你在 y 上运行 sum,你会得到预期的结果:
julia> sum(y)
8
通常,你会这样写:
julia> sum(skipmissing(x))
8
现在你可能会问,为什么在 Julia 中我们创建一个特殊对象来跳过缺失值。在其他语言中,比如 R,函数通常通过关键字参数让用户决定是否跳过缺失值。
有两个考虑因素。首先,编写 skipmissing(x) 是高效的。这里没有进行复制:这只是确保不会将缺失值传递给以 skipmissing(x) 作为参数的函数的一种方式。第二个原因是设计的可组合性。如果我们有一个 skipmissing(x) 对象,我们编写的函数(如 sum、mean 和 var)不需要显式处理缺失值。它们可以有一个实现,用户通过传递适当的参数来选择要操作的内容。
这有什么好处?在其他生态系统中,一些函数有适当的关键字参数来处理缺失值,而另一些则没有,在后一种情况下,用户必须手动处理。在 Julia 中,处理缺失值被抽象到了更高的层次。
在函数中启用缺失值传播
最后一种缺失值传播的场景涉及那些默认不传播缺失值的函数,因为它们的设计者决定不这样做。让我们编写一个具有这种行为的简单函数:
julia> fun(x::Int, y::Int) = x + y
fun (generic function with 1 method)
此函数只接受 Int 类型的参数;如果它接收到缺失值,则会报错:
julia> fun(1, 2)
3
julia> fun(1, missing)
ERROR: MethodError: no method matching fun(::Int64, ::Missing)
然而,在某些场景中,即使函数的设计者没有预见到有人可能想要传递缺失值给它,我们仍然想创建另一个基于原始函数并传播缺失值的函数。这个功能由 Missings.jl 包中的 passmissing 函数提供。以下是其使用的一个示例:
julia> using Missings
julia> fun2 = passmissing(fun)
(::Missings.PassMissing{typeof(fun)}) (generic function with 2 methods)
julia> fun2(1, 2)
3
julia> fun2(1, missing)
missing
这个想法很简单。passmissing 函数接受一个函数作为其参数,并返回一个新的函数。返回的函数,在本例中为 fun2,如果其任何位置参数是缺失值,则返回缺失值。否则,它使用传递的参数调用 fun。
现在你已经了解了围绕缺失值构建的 Julia 语言的基本功能。如果你想了解更多,请参阅 Julia 手册(docs.julialang.org/en/v1/manual/missing/)或 Missings.jl 包的文档(github.com/JuliaData/Missings.jl)。
总结一下,让我提一下,在集合(例如,Vector{Union{Missing, Int}})中允许缺失值与不允许缺失值的相同集合类型相比,会有轻微的性能和内存消耗开销(例如,Vector{Int})。然而,在大多数情况下,这并不明显。
练习 7.1 给定向量 v = ["1", "2", missing, "4"],解析它,以便将字符串转换为数字,而缺失值保持为缺失值。
7.3 从 NBP Web API 获取时间序列数据
我们现在可以回到我们的问题,即分析 PLN/USD 汇率。在这个例子中,假设我们想要获取 2020 年 6 月所有天的数据。使用你在 7.1 节中学到的知识,我们将创建一个函数,从单日获取数据,然后将其应用于所有相关日期。但我们如何列出 2020 年 6 月的所有天?
你首先需要学习如何在 Julia 中处理日期。完成这个之后,我们将回到我们的主要任务。
时间序列分析在数据科学项目中通常是必需的。为了正确处理此类数据,你需要知道如何向观测值添加时间戳。这可以通过使用 Julia 标准库中的 Dates 模块方便地实现。
7.3.1 与日期一起工作
在本节中,我将向你展示如何在 Julia 中操作日期。日期支持由 Dates 标准模块提供。创建日期对象的最简单方法是将字符串传递给 Date 构造函数,该字符串的格式与你在 7.1 节中看到的 YYYY-MM-DD 格式相同。以下是一个示例:
julia> using Dates
julia> d = Date("2020-06-01")
2020-06-01
现在我们可以检查绑定到变量 d 的对象,首先检查其类型,然后提取年、月和日期部分:
julia> typeof(d)
Date
julia> year(d)
2020
julia> month(d)
6
julia> day(d)
1
除了像年、月和日这样的自然函数之外,Julia 还提供了几个更高级的函数。在这里,让我向你展示如何查询日期的星期数和英文名称:
julia> dayofweek(d)
1
julia> dayname(d)
"Monday"
你可以在 Julia 手册的“API 参考”部分找到可用函数的完整列表(mng.bz/derv)。
如果你有一些不遵循 YYYY-MM-DD 格式的日期字符串,你可以使用 DateFormat 对象来指定自定义日期格式。有关详细信息,请参阅 Julia 手册的“构造函数”部分(mng.bz/rn6e)。
构建日期的另一种常见方式是传递表示日期的年、月和日的数字:
julia> Date(2020, 6, 1)
2020-06-01
最后一个构造函数为我们提供了一个简单的方法来创建从 2020 年 6 月开始的日期向量,使用广播,如下所示。
列表 7.5 创建 2020 年 6 月所有日期的向量
julia> dates = Date.(2020, 6, 1:30)
30-element Vector{Date}:
2020-06-01
2020-06-02
2020-06-03
⋮
2020-06-28
2020-06-29
2020-06-30
这种创建日期对象序列的方式很简单,但仅适用于跨一个月的日期。如果我们想要从 2020 年 5 月 20 日到 2020 年 7 月 5 日的日期呢?为了解决这个问题,我们需要使用持续时间度量。对于我们的目的,日持续时间是合适的。例如,Day(1)是一个表示等于一天的时间间隔的对象:
julia> Day(1)
1 day
现在重要的是,你可以添加带有持续时间的日期来获取新的日期。例如,要获取 2020 年 6 月 1 日之后的第二天,你可以这样写:
julia> d
2020-06-01
julia> d + Day(1)
2020-06-02
你可能已经猜到了如何写一个由一天分隔的日期范围。你可以通过使用范围来实现这一点。以下是从 2020 年 5 月 20 日到 2020 年 7 月 5 日(包括)的所有日期:
julia> Date(2020, 5, 20):Day(1):Date(2020, 7, 5)
Date("2020-05-20"):Day(1):Date("2020-07-05")
你可以通过使用 collect 函数来检查这个范围是否产生了预期的值集,将其转换为 Vector:
julia> collect(Date(2020, 5, 20):Day(1):Date(2020, 7, 5))
47-element Vector{Date}:
2020-05-20
2020-05-21
2020-05-22
⋮
2020-07-03
2020-07-04
2020-07-05
存在着其他时间持续度的度量,例如周和年。你可以在 Julia 手册的“TimeType-Period Arithmetic”部分中了解更多关于它们和日期算术规则的信息(mng.bz/VyBW)。
最后,Julia 还允许你与时间和日期时间对象一起工作。详细信息可以在 Julia 手册的“Dates”部分中找到(docs.julialang.org/en/v1/stdlib/Dates/)。
练习 7.2 创建一个包含 2021 年每月第一天日期的向量。
7.3.2 从 NBP Web API 获取一系列日期的数据
既然我们已经创建了一个包含我们想要获取 PLN/USD 汇率数据的日期向量(在列表 7.5 中),那么让我们在下一个列表中编写一个函数来获取特定日期的数据。我们将遵循第 7.1 节中解释的步骤,这样我们就可以轻松地收集所有所需日期的数据。
列表 7.6 获取特定日期 PLN/USD 汇率数据的函数
function get_rate(date::Date)
query = "https://api.nbp.pl/api/exchangerates/rates/" *
"a/usd/$date/?format=json"
try
response = HTTP.get(query)
json = JSON3.read(response.body)
return only(json.rates).mid
catch e
if e isa HTTP.ExceptionRequest.StatusError
return missing
else
rethrow(e)
end
end
end
这个函数对我们的第 7.1 节中的代码进行了一些小的修改。首先,我们接受一个 Date 作为其参数,这样我们就可以确保用户不会用任意值调用我们的 get_rate 函数,这个值会被插入到查询字符串中。此外,请注意,我将 get_rate 函数定义为只接受标量 Date。这是在 Julia 中定义函数的推荐风格,正如我在第五章中解释的那样。稍后我们将在这个日期向量上广播这个函数,以获取 PLN/USD 汇率向量。
接下来,为了形成查询字符串,我们将日期插入其中。正如我们在第六章中讨论的,插入是通过使用$字符后跟插入变量的名称来完成的。以下是一个示例:
julia> d
2020-06-01
julia> "d = $d"
"d = 2020-06-01"
为了再举一个例子,这里有一种方法来插值我们在列表 7.5 中定义的 dates 向量中的第一个值(插值部分是加粗的):
julia> "https://api.nbp.pl/api/exchangerates/rates/" *
"a/usd/$(dates[1])/?format=json"
"https://api.nbp.pl/api/exchangerates/rates/a/usd/2020-06-01/?format=json"
这次,作为一个例子,我们插值 dates[1],为了确保它得到适当的插值,我们将其括在括号中。如果我们省略括号,整个 dates 向量都会被插值,然后是[1]字符序列,这并不是我们想要的(再次强调,插值部分是加粗的):
julia> "https://api.nbp.pl/api/exchangerates/rates/" *
"a/usd/$dates[1]/?format=json"
"https://api.nbp.pl/api/exchangerates/rates/a/usd/[Date(\"2020-06-01\"),
Date(\"2020-06-02\"), Date(\"2020-06-03\"), Date(\"2020-06-04\"),
Date(\"2020-06-05\"), Date(\"2020-06-06\"), Date(\"2020-06-07\"),
Date(\"2020-06-08\"), Date(\"2020-06-09\"), Date(\"2020-06-10\"),
Date(\"2020-06-11\"), Date(\"2020-06-12\"), Date(\"2020-06-13\"),
Date(\"2020-06-14\"), Date(\"2020-06-15\"), Date(\"2020-06-16\"),
Date(\"2020-06-17\"), Date(\"2020-06-18\"), Date(\"2020-06-19\"),
Date(\"2020-06-20\"), Date(\"2020-06-21\"), Date(\"2020-06-22\"),
Date(\"2020-06-23\"), Date(\"2020-06-24\"), Date(\"2020-06-25\"),
Date(\"2020-06-26\"), Date(\"2020-06-27\"), Date(\"2020-06-28\"),
Date(\"2020-06-29\"), Date(\"2020-06-30\")][1]/?format=json"
最终的修改是,根据我在第二章中介绍的规则,我们在代码中明确写出 return 关键字两次,以确保 get_rate 函数将返回的值清晰可见。
现在我们已经准备好在下一列表中获取 2020 年 6 月的 PLN/USD 汇率。
列表 7.7 获取 2020 年 6 月的 PLN/USD 汇率
julia> rates = get_rate.(dates)
30-element Vector{Union{Missing, Float64}}:
3.968
3.9303
3.9121
⋮
missing
3.9656
3.9806
我们在 get_rate 函数后面使用点(.)来将其应用于 dates 向量的所有元素。此外,结果是一个具有元素类型 Union{Float64, Missing}的 Vector,这意味着在结果中,我们有缺失值和浮点数的混合。
7.4 分析从 NBP Web API 获取的数据
在列表 7.5 中定义了 dates 变量,在列表 7.7 中定义了 rates 变量后,让我们分析数据以了解其内容。我们想要做以下事情:
-
计算数据的基本摘要统计量:率的向量的平均值和标准差
-
分析我们向量中缺失数据出现的星期几
-
在图表上显示 PLN/USD 汇率
你将获得的关键新技能是在考虑数据的时序性质和适当处理缺失值的同时进行此分析。
7.4.1 计算摘要统计量
首先,我们想要计算列表 7.7 中定义的率的向量的平均值和标准差。我们的第一次尝试使用了 Statistics 模块中的 mean 和 std 函数:
julia> using Statistics
julia> mean(rates)
missing
julia> std(rates)
missing
不幸的是,这并不是我们预期的结果。如第 7.2 节所述,我们需要额外使用 skipmissing 函数:
julia> mean(skipmissing(rates))
3.9452904761904755
julia> std(skipmissing(rates))
0.022438959529396577
在分析期间,PLN/USD 汇率略低于 4 PLN/USD,标准差约为 0.02。
7.4.2 找出哪些星期几缺失值最多
如第 7.3 节所述,dayname 函数返回给定日期的英文名称。因此,我们可以使用你在第六章中学到的 proptable 函数,通过交叉表 dayname.(dates)和 ismissing .(rates)来获取所需的结果,如下一列表所示。
列表 7.8 率向量中缺失数据天数的频率表
julia> using FreqTables
julia> proptable(dayname.(dates), ismissing.(rates); margins=1)
7×2 Named Matrix{Float64}
Dim1 ╲ Dim2 │ false true
────────────+─────────────
Friday │ 1.0 0.0
Monday │ 1.0 0.0
Saturday │ 0.0 1.0
Sunday │ 0.0 1.0
Thursday │ 0.75 0.25
Tuesday │ 1.0 0.0
Wednesday │ 1.0 0.0
我们可以看到,周六和周日我们总是有缺失数据。对于其他所有日子,除了周四,没有数据缺失。让我们找出哪些周四有问题。为此,创建一个布尔向量,使用广播找到满足两个条件的索引:
julia> dayname.(dates) .== "Thursday" .&& ismissing.(rates)
30-element BitVector:
0
0
0
⋮
0
0
0
我们可以使用这个布尔向量来找到日期向量中满足条件的元素,如下列所示。
列表 7.9 查找汇率向量包含缺失值的星期四
julia> dates[dayname.(dates) .== "Thursday" .&& ismissing.(rates)]
1-element Vector{Date}:
2020-06-11
我们可以看到,一个单独的日子符合我们的条件。你可以确认这个日期在波兰是一个国家假日,所以结果看起来是合理的。
你将在第二部分学到更多与此示例相关的细节:
-
在列表 7.8 中展示的表中,日期是按字母顺序排序的。在第二部分,你将了解到,你可以使用 CategoricalArrays.jl 包按一周中日期的标准顺序对行进行排序。
-
在列表 7.9 中,所使用的条件看起来有点复杂。在第二部分,你将了解到,如果你将日期和汇率向量保存在 DataFrame 中,你可以更容易地进行选择。
7.4.3 绘制 PLN/USD 汇率图
作为最后一步,让我们创建一个 PLN/USD 汇率图的图表。从最简单的方法开始,将日期和汇率向量传递给绘图函数:
julia> using Plots
julia> plot(dates, rates;
xlabel="day", ylabel="PLN/USD", legend=false, marker=:o)
图 7.3 显示了该图表。它看起来不太美观,因为它在汇率向量中缺失值的地方有间隙。

图 7.3 在这个日期与汇率向量的基本图表中,我们在向量中值缺失的地方有间隙。
为了修复这个图表,让我们跳过日期和汇率向量中包含缺失值的那些日子。我们再次可以使用有效的索引的布尔向量。语法稍微有点棘手:
julia> rates_ok = .!ismissing.(rates)
30-element BitVector:
1
1
1
⋮
0
1
1
我们应该如何读取它?对于传递的单个值,!ismissing 是 ismissing 函数产生的返回值的否定。现在我们知道我们需要在感叹号(!)操作符前加上点(.)来广播它,但我们需要在 ismissing 部分后加上点(.),这给了我们我使用的语法。
因此,可以通过以下命令生成跳过缺失值的图表:
julia> plot(dates[rates_ok], rates[rates_ok];
xlabel="day", ylabel="PLN/USD", legend=false, marker=:o)
与 7.4.2 节末尾提到的笔记类似,如果数据存储在 DataFrame 中,这个操作可以做得更干净。我们将在第二部分讨论这个话题。
图 7.4 显示了你应该得到的结果。观察图表的 x 轴,观测值根据它们的日期适当地间隔。这意味着在图 7.4 中,从视觉上看,我们对图 7.3 中数据缺失的日期进行了线性插值——即图表中的点通过直线连接。

图 7.4 跳过缺失值的日期与汇率向量图表。与图 7.3 不同,这个图表是连续的。
你可以使用 Impute.jl 包中的 Impute.interp 函数执行数据的线性插值。给定一个向量,这个函数通过线性插值使用两个非缺失值之间的所有缺失值:
julia> using Impute
julia> rates_filled = Impute.interp(rates)
30-element Vector{Union{Missing, Float64}}:
3.968
3.9303
3.9121
⋮
3.9669666666666665
3.9656
3.9806
Impute.jl 包具有许多更多功能,这些功能有助于处理缺失数据。我建议你查看该包的仓库(github.com/invenia/Impute.jl)以获取详细信息。
为了完成我们的项目,让我们将日期与rates_filled向量的散点图添加到图 7.4 所示的图表中,以检查是否确实使用了线性插值:
julia> scatter!(dates, rates_filled, markersize=3)
我们使用带有感叹号结尾的 scatter! 函数来更新之前的图表,并添加额外的数据。图 7.5 展示了我们操作的结果。

图 7.5:一个日期与利率向量的散点图,跳过了缺失值,并添加了一个散点图,该散点图表示使用线性插值填充的缺失数据。该图表的形状与图 7.4 相同。
如果你想要了解更多关于可以传递给 Plots.jl 包提供的绘图函数的属性,其手册中的“属性”部分([docs.juliaplots.org/stable/attributes/](https://docs.juliaplots.org/stable/attributes/))是一个很好的起点。在图 7.3-7.5 所展示的图表的上下文中,例如,你可能会发现使用 xticks 关键字参数定义自定义的 x 轴刻度很有用,这样它们就可以使用与默认不同的间距或显示格式。使用这个功能,让我们用具有非缺失数据的日期的 x 轴刻度来重现图 7.4。图 7.6 展示了我们想要的结果。

图 7.6:在日期与利率向量的散点图中添加了具有数据的日期的 x 轴刻度,跳过了缺失值。
与图 7.4 相比,图 7.6 有三个变化。首先,我们有了位于日期处的 x 轴刻度。我们通过在绘图调用中添加 xticks=dates[rates_ok]关键字参数来实现这一点。其次,由于现在有很多刻度,我们垂直打印它们,这是通过 xrot=90 关键字参数实现的。第三,由于标签占据了更多的垂直空间,我们通过 bottommargin=5Plots.mm 关键字参数增加了图表的底部边距。
你可能会问 5Plots.mm 代表什么。这个表达式定义了一个代表 5 毫米的值。这个功能由 Measures.jl 包提供,并在 Plots.jl 包中可用。我们需要使用 Plots.前缀,因为 Plots.jl 没有导出 mm 常量。在 Plots.jl 中使用长度的绝对度量(在我们的例子中是毫米)来定义图表的尺寸的能力,当你想要创建生产质量的图表时通常很有用。
下面是生成图 7.6 的完整代码:
julia> plot(dates[rates_ok], rates[rates_ok];
xlabel="day", ylabel="PLN/USD", legend=false, marker=:o,
xticks=dates[rates_ok], xrot=90, bottommargin=5Plots.mm)
为了总结本章内容,尝试以下更高级的练习。
练习 7.3 NBP Web API 允许你获取一段时间内日期的汇率序列。例如,查询 "https://api.nbp.pl/api/exchangerates/rates/a/usd/2020-06-01/2020-06-30/?format=json" 返回了 2020 年 6 月的汇率序列,这些汇率存在于特定日期——换句话说,没有汇率的日期会被跳过。你的任务是解析这个查询的结果,并确认获得的结果与我们收集的日期和汇率向量中的数据一致。
摘要
-
JSON 是一种常用于交换数据的存储格式。它允许你处理复杂的数据结构,包括对象(提供键值映射)和数组(存储值序列)。
-
来自 HTTP.jl 包的 HTTP.get 函数可以用来发送 HTTP GET 请求消息。如果请求失败,此函数会抛出 HTTP .ExceptionRequest.StatusError 异常。
-
你可以通过使用 try-catch-end 块来处理 Julia 抛出的异常。请谨慎使用此功能,并仅捕获你真正想要处理的异常。
-
你可以通过使用 JSON3.jl 包中的 JSON3.read 函数在 Julia 中解析 JSON 数据。结果值可以使用标准的 Julia 语法访问:可以使用点(.)语法检索对象键,使用索引访问数组元素。
-
Julia 提供了缺失值,用于在观察中某个变量的值不存在,但理论上存在有效值的情况。这个特殊值在 Julia 中被引入,因为它经常被需要,因为现实生活中的数据很少是完整的。
-
许多标准的 Julia 函数会传播缺失值:如果它们接收到缺失值作为参数,它们会返回缺失值作为结果。因此,如果你使用这些函数,你不需要添加任何特殊代码来处理缺失数据。
-
如果你有一个可能取此值的变量,可以使用 coalesce 函数为缺失值提供默认值。这在编写逻辑条件时特别有用。如果你想表达式在 x 缺失时产生 false,而在其他情况下保留 x 的值,则编写 coalesce(x, false)。
-
如果你的数据集中包含缺失值,你可以使用 skipmissing 包装器来高效地创建(不复制数据)另一个集合,该集合已移除这些缺失值。
-
如果你有一个默认不接受缺失值的函数,你可以使用 passmissing 函数将其包装成传播缺失值的函数。
-
Dates 标准模块提供了处理日期、时间和日期时间对象的功能。如果你处理时间数据,你可能会使用这个模块。
-
您可以使用日期对象来表示日期作为时间的实例。几个对象(如 Day 和 Month)允许您表示时间段。您可以对表示时间和时间段实例的对象执行算术运算。日期标准模块的这项内置功能很有用,因为涉及日期的算术规则很复杂,所以您不需要自己实现它们。
-
Julia 提供了许多便利函数,例如 dayofweek 和 dayname,允许您查询日期对象。在分析时间数据时,此类信息通常很有用。
-
当使用来自 Plots.jl 包的 plot 函数绘制数据时,缺失的数据会被跳过。在设计您的图表时,您需要考虑这一点。
-
Plots.jl 正确处理传递给它的日期对象,并确保图表上点的间距遵循日历中日期之间的距离。这是一个重要特性,因为图表上的距离与日期之间的间隔成正比。
-
如果您想在数据序列的缺失值上执行线性插值,可以使用来自 Impute.jl 包的 Impute.interp 函数。使用此包可以节省您实现插值代码的精力。
第二部分数据分析工具箱
在第一部分,您学习了如何使用 Base Julia 的一部分数据结构来加载数据和分析数据,例如向量、矩阵、字典和命名元组。我相信您会在项目中找到这些技能很有用。然而,许多标准数据处理任务被用户反复需要——例如,从 CSV 文件中读取数据或聚合数据。由于您不希望每次都从头开始重新实现这些任务,因此设计了一系列 Julia 包来使这些任务变得简单高效。
在本书的第二部分,您将学习如何使用 DataFrames.jl 和相关包来构建复杂的数据分析管道。我们将涵盖广泛的主题,从获取和读取数据开始,到数据转换,最后是构建简单的数据分析模型和可视化。
您可以预期,这些章节涵盖的主题将从数据科学和编程角度逐渐变得更加具有挑战性。我选择本部分的内容以确保学习后,您将准备好进行数据分析项目,并学习和使用书中未涵盖的包。
本部分由七个章节组成,如下所示:
-
第八章教您如何从 CSV 文件创建数据框并执行数据框的基本操作。它还展示了如何在 Apache Arrow 和 SQLite 数据库中处理数据,处理压缩文件,以及进行基本的数据可视化。
-
第九章教您如何从数据框中选择行和列。您还将学习如何构建和可视化局部估计散点图平滑(LOESS)回归模型。
-
第十章涵盖了创建新数据框和用新数据填充现有数据框的各种方法。它讨论了 Tables.jl 接口,这是一个表概念的实现无关抽象。在本章中,您还将学习如何将 Julia 与 R 集成以及如何序列化 Julia 对象。
-
第十一章教您如何将数据框转换为其他类型的对象。一个基本类型是分组数据框。您还将了解类型稳定的代码和类型盗用等重要通用概念。
-
第十二章专注于数据框对象的转换和变异——特别是使用 split-apply-combine 策略。此外,本章还涵盖了使用 Graphs.jl 包处理图数据的基础知识。
-
第十三章讨论了 DataFrames.jl 包提供的先进数据框转换选项,以及数据框排序、连接和重塑。它还教您如何在数据处理管道中链式执行多个操作。从数据科学的角度来看,本章展示了如何在 Julia 中处理分类数据并评估分类模型。
-
第十四章展示了如何在 Julia 中构建一个提供由分析算法生成数据的 Web 服务。此外,它还展示了如何实现蒙特卡洛模拟,并利用 Julia 的多线程功能来加快它们的运行速度。
使用数据框的 8 个基本步骤
本章涵盖
-
处理压缩文件
-
读取和写入 CSV 文件、Apache Arrow 数据和 SQLite 数据库
-
从数据框中获取列
-
计算数据框内容的摘要统计量
-
使用直方图可视化数据分布
在本章中,你将学习使用 DataFrames.jl 包在 Julia 中处理数据框的基本原理。"数据框对象"是灵活的数据结构,允许你处理表格数据。正如我在第一章中解释的,表格数据通常,尤其是数据框,是一个由单元格组成的二维结构。每一行都有相同数量的单元格,提供关于数据的一个观测值的信息。每一列都有相同数量的单元格,存储关于观测值之间相同特征的信息,并且有一个名称。
在阅读第一部分后,你已经掌握了使用 Julia 分析数据的基本技能。从本章开始,你将学习如何在 Julia 中高效地执行数据分析任务。我们首先解释如何处理表格数据,因为大多数统计数据集都采用这种形式。因此,几乎所有用于数据科学的生态系统都提供了数据框类型。例如:
-
每个关系型数据库都通过一个或多个表来组织数据。
-
在 R 中,data.frame 对象是语言中内置的一个核心概念。多年来,这个概念在这个生态系统中提出了多种替代实现;其中最受欢迎的是 tibble 和 data.table。
-
在 Python 中,pandas 包非常流行,其核心组件是 DataFrame 类型。
本章的目标是介绍你如何使用数据框。我们将通过执行一个简单的数据分析任务来实现这一目标。
许多人喜欢玩游戏。在 COVID-19 大流行期间,在线下棋变得越来越受欢迎。Netflix 还通过其 2020 年的迷你剧集《王后棋局》进一步激发了这种兴趣。如果你想了解更多关于近期对棋类游戏日益增长的兴趣,你可能想查看 Chess.com 博客文章mng.bz/O6Gj。
许多人通过解决棋盘谜题来练习下棋。因此,一个自然的问题就是:什么使一个棋盘谜题变得出色?我们将通过本章将要进行的分析来探讨这个话题。具体来说,我们将研究谜题的流行程度与其难度之间的关系。也许人们最喜欢简单的谜题。或者也许相反,非常难的谜题,需要找到巧妙的走法,才是最有吸引力的。本章和第九章的目标是向你展示如何找到这些问题的答案。
就像任何数据科学项目一样,为了对问题有深入了解,我们需要可以分析的数据。幸运的是,有关谜题的数据在网络上免费提供。Lichess (lichess.org) 是一个免费的开源国际象棋服务器。其功能之一是允许用户解决国际象棋谜题。
您可以从 database.lichess.org 下载可用的谜题数据库。数据在 Creative Commons CC0 许可下分发。包含谜题的文件作为一个 bzip2 存档 (mng.bz/YKgj) 提供。它包含关于超过两百万个谜题的信息,包括给定谜题被玩了多少次,谜题有多难,Lichess 用户有多喜欢这个谜题,以及谜题具有哪些国际象棋主题。
我们的目标是检查谜题难度与用户是否喜欢它之间的关系。我们将在第九章进行这项分析。然而,在我们能够从数据中获得见解之前,我们需要获取它、加载它,并对其进行初步分析。这些准备工作是本章的目标。具体来说,我们将执行以下步骤:
-
从网络上下载压缩的谜题存档。
-
解压缩它。
-
将其内容读入一个数据框。
-
使用直方图来分析存储在此数据集中所选特征的分布。
所有这些任务几乎需要在每个数据科学项目中执行。因此,学习如何高效地执行这些任务是有用的。为了实现这些目标,我按照以下方式组织了本章:
-
在第 8.1 节中,你将学习如何在 Julia 中处理 bzip2 压缩数据。在实际应用中,知道如何编程处理压缩归档通常是必需的,因为许多来源中的数据通常为了存储而进行压缩。
-
在第 8.2 节中,我将向您展示如何将 CSV 文件读入 DataFrame 对象并快速检查其内容。
-
第 8.3 节介绍了从数据框中获取数据的最基本方法:通过从中选择一个列。
本章中我们处理的数据源是 CSV 格式。为了向您展示如何读取和写入使用不同标准存储的数据,在第 8.4 节中,你将学习如何处理 Apache Arrow 格式和 SQLite 数据库。
8.1 获取、解包和检查数据
要处理 Lichess 上可用的谜题数据库,我们首先需要从网络上下载它。接下来,我们将解压缩它,以便稍后可以将其读入 DataFrame。
我将向您展示如何解包存储在 bzip2 存档中的数据。然而,同样的方法也可以用于解压缩其他格式创建的存档。数据压缩通常被使用,因为它可以减少存储大小或传输时间,因此了解如何处理压缩数据在实践中是有用的。
注意:在包含本书源代码的 GitHub 仓库中,我已经包含了本节中使用的 puzzles.csv.bz2 文件,以确保本章和第九章中展示的结果的可重复性。Lichess 棋盘数据库不断更新,因此如果你选择使用其最新版本而不是 GitHub 上的版本,你可能会得到略微不同的结果,代码可能需要一些小的修改。因此,在示例代码的第一步中,我们将文件保存为 new_puzzles.csv.bz2,这样它就不会覆盖我们在分析中使用的 puzzles.csv.bz2 文件。
8.1.1 从网络上下载文件
由于下载的文件很大,我们添加了一个步骤来检查文件是否已经本地存在,以避免在不需要的情况下再次获取:
julia> import Downloads
julia> if isfile("new_puzzles.csv.bz2") ❶
@info "file already present" ❷
else
@info "fetching file" ❸
Downloads.download("https://database.lichess.org/" * ❸
"lichess_db_puzzle.csv.bz2", ❸
"new_puzzles.csv.bz2") ❸
end
[ Info: file already present
❶ 检查文件是否已经存在
❷ 如果是,则打印确认信息
❸ 如果不是,则通知用户需要从网络上获取数据
我们使用 @info 宏来打印适当的状态消息。在上面的打印输出中,我展示了 puzzles.csv.bz2 已经存在于工作目录中。在这种情况下,isfile("new_puzzles.csv.bz2") 检查产生 true。
在 Julia 中创建事件日志
Julia 随带日志模块,该模块允许你将计算进度记录为事件日志。@info 宏是这个模块的一部分,用于记录信息性消息。其他常见的事件严重程度级别通过宏支持:@debug、@warn 和 @error。
日志模块允许你灵活地决定哪些事件被记录以及如何记录。例如,你可以决定只记录错误消息并将它们写入文件。如果你想了解更多关于如何在 Julia 程序中配置日志的信息,请参阅 Julia 手册中的“日志”部分 (docs.julialang.org/en/v1/stdlib/Logging/) 以获取详细信息。
8.1.2 使用 bzip2 归档
存储在 GitHub 仓库中并用于本章的 puzzles.csv.bz2 文件是使用 bzip2 算法压缩的 (www.sourceware.org/bzip2/),这由 .bz2 文件扩展名指示。我们将使用 CodecBzip2.jl 包来解压缩它。我们首先将文件内容读取为 UInt8 值的向量(单个 UInt8 值是 1 字节),然后使用转码函数将其解压缩为一个字节数组向量:
julia> using CodecBzip2
julia> compressed = read("puzzles.csv.bz2") ❶
94032447-element Vector{UInt8}:
0x42
0x5a
0x68
⋮
0x49
0x5f
0x30
julia> plain = transcode(Bzip2Decompressor, compressed) ❷
366020640-element Vector{UInt8}:
0x30
0x30
0x30
⋮
0x32
0x30
0x0a
❶ 将压缩数据读入一个字节数组向量
❷ 使用 Bzip2Decompressor 编解码器解压缩数据
压缩数据有 94,032,447 字节,解压缩后变为 366,020,640 字节。因此,该数据集的压缩比大约为 4:
julia> length(plain) / length(compressed)
3.892492981704496
理解转码函数
在我们的例子中,我们使用转码函数来解压缩一个字节数组。在 Julia 中,这个函数用于两个上下文:更改字符串编码和转码数据流。
第一个用例是在 Unicode 编码之间转换数据。正如你在第六章中学到的,Julia 中的字符串是 UTF-8 编码的。如果你有一个以 UTF-16 或 UTF-32 编码的源数据流,你可以使用 transcode 函数将其转换为 UTF-8。同样,你也可以将 UTF-8 编码的数据转换为 UTF-16 或 UTF-32。
使用 transcode 函数的第二个情况是将数据流进行转码。在这种情况下,您应该提供一个要应用于此数据的编解码器和一个字节数组。编解码器是一个程序,它将数据从源格式转换为另一种目标格式。转码的最常见用途是数据压缩、解压缩和更改数据格式。以下是一个选定的可用编解码器列表,以及提供它们的包:
-
gzip、zlib 和 deflate 格式压缩和解压缩:CodecZlib.jl
-
bzip2 格式压缩和解压缩:CodecBzip2.jl
-
xz 格式压缩和解压缩:CodecXz.jl
-
zsdf 格式压缩和解压缩:CodecZstd.jl
-
base16、base32 和 base64 解码和编码:CodecBase.jl
我省略了所有这些格式和功能细节,因为我们在这本书中不需要它们。如果您想了解更多关于如何使用 transcode 函数的信息,请参阅相应包的文档。
我们很可能需要多次回到我们的未压缩数据。让我们将其写入 puzzles.csv 文件。
在保存 puzzles.csv 文件的代码中,我们使用了你在第六章中已经看到的 open 函数和 do-end 块的模式。新的东西是 write 函数的使用。它用于将数据的二进制表示写入文件。在我们的情况下,因为 plain 是 Vector{UInt8},所以我们将其原始内容写入文件。在将存储在 plain 向量中的未压缩数据写入文件之前,我们使用 println 函数向该文件写入一个字符串。这是因为,正如你很快就会学到的,原始 CSV 数据没有包含列名的标题。我使用了 Lichess 网站上给出的列名(database.lichess.org/#puzzles):
julia> open("puzzles.csv", "w") do io
println(io, "PuzzleId,FEN,Moves,Rating,RatingDeviation," *
"Popularity,NbPlays,Themes,GameUrl") ❶
write(io, plain) ❷
end
366020640
❶ 将第二个传入参数的文本表示写入 io,后跟一个换行符
❷ 将第二个传入参数的二进制表示写入 io
8.1.3 检查 CSV 文件
让我们快速检查 puzzles.csv 文件的内容:
julia> readlines("puzzles.csv")
运行此命令在终端中给出以下输出:

的确,这个文件看起来是一个格式正确的 CSV 文件。这种格式是存储表格数据的流行方式,其指定如下:
-
文件的第一行包含由逗号(,)分隔的列名。
-
以下每一行都包含关于我们数据单个观测值(记录)的信息。在一行中,逗号分隔了引用表格连续列的单元格。每行中的列数必须等于数据第一行中定义的列名数量。
8.2 将数据加载到数据框中
现在我们已经解压缩了数据,让我们将其加载到数据框中。我们的 Lichess 数据存储在 CSV 格式,我故意选择这个例子,因为 CSV 是实践中使用最广泛的人类可读数据格式之一。它可以很容易地由电子表格编辑器读取和写入。因此,了解如何在 Julia 中处理 CSV 文件是值得的。
8.2.1 将 CSV 文件读取到数据框中
在 DataFrames.jl 库中定义的 DataFrame 类型是您在 Julia 中内存中存储表格数据最受欢迎的选项之一。要将磁盘上的 puzzles.csv 文件读取到 DataFrame 中,请使用 CSV.jl 包中的 CSV.read 函数:
julia> using CSV
julia> using DataFrames
julia> puzzles = CSV.read("puzzles.csv", DataFrame);
在最后一个表达式中,我使用分号 (😉 来抑制将数据框内容打印到屏幕。
CSV.read 函数不仅可以读取传递为字符串的文件名中的数据,还可以直接传递一个提供包含要读取数据的字节序列的源的源。在我们的例子中,我们有一个这样的源,因为它是一个绑定到普通变量的二进制向量。因此,我们也可以通过编写以下内容来创建我们的数据框:
julia> puzzles2 = CSV.read(plain, DataFrame;
header=["PuzzleId", "FEN", "Moves",
"Rating", "RatingDeviation",
"Popularity", "NbPlays",
"Themes", "GameUrl"]); ❶
julia> puzzles == puzzles2 ❷
true
❶ 在传递列名时使用 header 关键字参数从字节数组中读取数据
❷ 检查 puzzles 和 puzzles2 数据框是否相同
注意,在这种情况下,我们将 header 关键字参数传递给 CSV.read 函数,因为我们的原始数据没有列名。接下来,我们使用 == 操作符比较两个数据框,以确保它们相同。
选择 CSV.read 如何从源读取数据
在我们的例子中,我们看到了 CSV.read 函数允许传递 header 关键字参数来为创建的表提供列名。在 CSV.jl 文档(csv.juliadata.org/stable/reading.html)中,您可以找到读者支持的所有选项列表。我将总结几个最常用的关键字参数及其功能:
-
header—控制处理文件时如何处理列名。默认情况下,假设列名是输入的第一行/行。
-
limit—指定应从数据中读取的行数。默认情况下,读取所有数据。
-
misssingstring—控制解析输入数据时如何处理缺失值。默认情况下,空字符串被视为表示缺失值。
-
delim—解析在数据输入中查找的参数,用于分隔每行上的不同列。如果没有提供参数(默认),解析将尝试检测输入前 10 行上最一致的分隔符,如果无法检测到其他一致的分隔符,则回退到单个逗号(,)。
-
ignorerepeated—如果解析应该忽略列之间的连续分隔符,则使用此选项。此选项可用于解析固定宽度数据输入。默认情况下,它设置为 false。
-
dateformat—控制解析在数据输入中检测日期和时间值的方式。如果没有提供参数(默认),解析将尝试检测时间、日期和日期时间列。
-
decimal—在解析浮点值时使用,以指示浮点值的分数部分开始的位置。默认情况下,使用点(.)。
-
stringtype—控制字符串列的类型。默认情况下,使用第六章中讨论的 InlineString.jl 包,用于存储窄字符串的列,而 String 类型用于存储宽字符串的列。
-
pool—控制哪些列将以 PooledArray 的形式返回。我们已在第六章中讨论了这种类型。默认情况下,如果列存储字符串,且存储的唯一值数量小于其长度的 20%,并且唯一值的数量小于 500,则该列会被合并。
此外,我们不需要绑定到压缩和普通变量的值。因此,为了允许 Julia 释放这些对象分配的内存,我们将这两个变量都绑定为无:
julia> compressed = nothing
julia> plain = nothing
释放大型对象分配的内存
需要记住的是,如果一个大型对象绑定到一个在 Julia 程序中可访问的变量名,Julia 不会释放这些对象分配的内存。为了允许 Julia 重新回收此内存,你必须确保该对象不可访问。
在一个常见情况下,全局变量通常在交互会话中创建。由于在 Julia 中,你不能在变量绑定到值之后删除变量名(见 mng.bz/G1GA),解决方案是将变量名的绑定从指向一个大对象更改为无。
8.2.2 检查数据框的内容
让我们看一下以下列表中的 puzzles 数据框。
列表 8.1 将样本数据框打印到屏幕上
julia> puzzles
列表 8.1 的输出被裁剪了,正如你在这里可以看到的:

裁剪由三个点表示,并在打印输出的右下角的消息中指示,我们从中了解到还有七个列和 2,123,983 行没有被完全打印出来。你在计算机上运行此命令时得到的精确输出取决于显示它的窗口大小。
当打印 puzzles 数据框时,标题包含有关显示的列名称及其元素类型的信息。我们数据框中的每一行都是一个单独的谜题描述。请注意,PuzzleId 列使用五个字符来编码谜题标识符。CSV.read 函数自动检测这一事实,并使用 String7 类型来存储字符串。另一方面,FEN 和 Moves 列更宽,因此使用 String 类型。
在读取数据后,检查所有列是否产生了预期的结果是一个好习惯。要快速查看数据框的摘要统计信息,请使用 describe 函数,如以下列表所示。
列表 8.2 获取数据框列的摘要统计信息
julia> show(describe(puzzles); truncate=14)
你可以在这里看到结果:

我想向您展示 describe 函数的完整默认结果。因此,此示例还使用了 show 函数来自定义数据框显示。truncate 关键字参数允许您指定在截断之前输出列的最大显示宽度(您可以通过执行 ?show 来了解 show 函数支持的其他关键字参数,以检查其文档字符串)。
describe 函数返回一个新的数据框,其中每一行包含有关原始数据框中单个列的信息。默认情况下,describe 为每个源列生成以下统计信息:
-
variable—存储为符号的名称
-
mean—如果列包含数值数据,则值的平均值
-
min—如果列包含可以定义顺序的数据,则列的最小值
-
median—如果列包含数值数据,则值的中间值
-
max—如果列包含可以定义顺序的数据,则列的最大值
-
nmissing—缺失值的数量
-
eltype—存储的值的类型
describe 函数允许您进一步指定要计算的统计信息(这里列出的是默认值)并选择哪些列应计算摘要统计信息。如果您想了解详细信息,请参阅文档 (mng.bz/09wE)。
根据列表 8.2 中提供的摘要统计信息,我们准备对存储在 puzzles 数据框中的列进行解释:
-
PuzzleId—谜题的唯一标识符
-
FEN—对谜题起始位置的编码
-
Moves—解决谜题的移动
-
Rating—谜题的难度
-
RatingDeviation—对谜题难度评估的准确性
-
Popularity—用户对谜题的喜爱程度(越高越好)
-
NbPlays—给定谜题被玩过的次数
-
Themes—描述谜题中包含的棋类主题
-
GameUrl—从其中获取谜题的源游戏的 URL
在我们继续前进之前,让我们讨论在处理数据框时常用到的三个函数:ncol、nrow 和 names 函数。
The ncol function returns the number of columns in a data frame:
julia> ncol(puzzles)
9
nrow 函数返回数据框中的行数:
julia> nrow(puzzles)
2132989
最后,names 函数返回我们数据框中的列名向量(此函数具有更多功能,我们将在第九章中讨论):
julia> names(puzzles)
9-element Vector{String}:
"PuzzleId"
"FEN"
"Moves"
"Rating"
"RatingDeviation"
"Popularity"
"NbPlays"
"Themes"
"GameUrl"
8.2.3 将数据框保存到 CSV 文件
在我们结束本节之前,让我们看看如何将数据框保存回 CSV 文件。您使用 CSV.write 函数,其中第一个参数是目标文件名,第二个参数是要保存的表格:
julia> CSV.write("puzzles2.csv", puzzles)
"puzzles2.csv"
在此代码中,我们将谜题数据框保存到 puzzles2.csv 文件中。
检查原始 puzzles.csv 和 puzzles2.csv 文件是否相同将很有趣。为了执行此测试,我们将使用 read 函数,当传递一个文件作为单个参数时,它返回一个包含从文件中读取的字节的 Vector{UInt8}。以下是一个示例:
julia> read("puzzles2.csv")
386223179-element Vector{UInt8}:
0x50
0x75
0x7a
⋮
0x32
0x30
0x0a
因此,我们可以通过比较应用于它们的 read 函数的结果来检查文件 puzzles.csv 和 puzzles2.csv 是否相同:
julia> read("puzzles2.csv") == read("puzzles.csv")
true
事实上,这两个文件包含相同的数据。
选择 CSV.write 如何写入数据
与 CSV.read 函数类似,CSV.write 函数允许传递多个关键字参数来控制 CSV 数据应如何写入。您可以在 CSV.jl 包的文档中找到所有选项(csv.juliadata.org/stable/writing.html)。以下是最重要的几个:
-
delim—用作字段分隔符的字符或字符串。默认为逗号(,)。
-
missingstring—用于打印缺失值的字符串。默认情况下,使用空字符串。
-
dateformat—要使用的日期格式字符串。默认为由 Dates 模块指定的格式。
-
append—是否将写入追加到现有文件中。如果为 true,则不会写入列名;默认为 false。
-
compress—控制是否使用标准 gzip 压缩来压缩写入的输出。默认情况下,使用 false。
-
decimal—在写入浮点数时用作小数点的字符。默认为点(.)。
8.3 从数据框中获取列
为了能够执行我们的分析,我们需要学习如何从数据框中获取数据。这类操作中最常见的是提取单个列。DataFrames.jl 提供了几种进行此操作的方法。让我们逐一调查它们。
为了专注于分析 Lichess 谜题数据的任务,我们将特别想要从谜题数据框中创建 Rating、RatingDeviation、Popularity 和 NbPlays 列的直方图,因为它们将在我们的进一步分析中使用。
8.3.1 理解数据框的存储模型
在内部,DataFrame 对象将数据存储为向量的集合。每个向量代表数据框的一列,并分配一个名称和一个编号。让我们在表 8.1 中可视化这一点。
表 8.1 拼图数据框的结构
| 列编号 | 列名称 | 列向量 |
|---|---|---|
| 1 | PuzzleId | ["00008", "0000D", "0009B", "000aY", ...] |
| 2 | FEN | ["r6k/pp2r2p/ ... /7K b - - 0 24", ...] |
| 3 | Moves | ["f2g3 e6e7 b2b1 b3c1 b1c1 h6c1", ...] |
| 4 | Rating | [1765, 1525, 1102, 1320, 1560, 1039, ...] |
| 5 | RatingDeviation | [74, 74, 75, 74, 76, 80, 75, ...] |
| 6 | Popularity | [93, 97, 85, 92, 88, 85, 80, ...] |
| 7 | NbPlays | [493, 9211, 503, 395, 441, 54, ...] |
| 8 | Themes | ["crushing ... middlegame", ...] |
| 9 | GameUrl | ["https://lichess.org/.../black#48", ...] |
例如,在内部,编号为 4 的列名为 Rating,存储了一个表示拼图难度的整数向量:[1765, 1525, 1102, 1320, 1560, ...]。
数据框的存储布局被选择以确保对数据框列执行的操作非常快。本书将讨论多个此类操作。让我们从最简单的一个开始:从数据框中提取列。
8.3.2 将数据框列视为属性
在第四章中,你学习了关于 NamedTuple 类型以及使用 struct 关键字参数创建的复合类型。我们讨论了你可以通过使用点号(.)后跟字段名称来访问 NamedTuple 或复合类型的字段。相同的语法允许访问数据框的列。
如果你考虑拼图数据框并想从中提取 Rating 列,请编写以下内容:
julia> puzzles.Rating
2132989-element Vector{Int64}:
1765
1525
1102
⋮
980
1783
2481
之前,在第五章中,我说过点号(.)允许用户访问结构体的字段。那么,DataFrame 类型是如何允许用户使用这种语法来访问其列的呢?
原因是 Julia 在结构体对象的字段和其属性之间做出了区分。当你使用点号(.)语法时,你可以访问对象的属性。默认情况下,对象的属性与其字段相同,但可以覆盖这种行为(技术上,你需要向 getproperty 函数添加适当的方法;更多信息请参见mng.bz/K0Bg)。这正是 DataFrame 类型所做的事情。它不是暴露其字段,而是允许用户使用点号(.)语法来访问其列,因为在实践中这要更有用。
注意,字段名称是类型定义的一部分,因此具有此类型的每个值都有相同的字段。相反,如果类型覆盖了属性的定义,那么具有相同类型的值之间的属性可以不同。例如,所有具有 DataFrame 类型的值都有相同的字段,但它们的属性取决于给定数据框存储的列名称。
你可以通过使用 fieldnames 函数来获取 DataFrame 类型的字段列表。如果你使用 DataFrames.jl 1.3 版本工作,调用 fieldnames(DataFrame) 将会得到一个包含 (:columns, :colindex) 的元组。(DataFrame 对象存储哪些字段是实现细节,并且它们可能会随着 DataFrames.jl 的版本而变化。)内部字段存储构成 DataFrame 列的向量,以及列名称和它们的数字映射。如果你想要从 DataFrame 类型的 df 变量中提取字段,你可以使用 getfield 函数。例如,getfield(df, :columns) 返回一个存储在数据帧中的向量。
图 8.1 展示了两个样本数据帧的字段和属性之间的关系。

图 8.1 由于两个数据帧具有相同的类型,它们具有相同的字段名称。这些字段名称是 DataFrame 类型的定义的一部分。相反,由于两个数据帧具有不同的列,它们的属性名称也不同。DataFrame 类型的属性定义为与给定实例的列名称相对应。
虽然在技术上可能,但你永远不应该直接从数据帧中提取字段。DataFrame 类型的内部布局被认为是私有的,并且可能会在未来发生变化。我仅介绍这个主题是为了确保你理解 Julia 中对象字段和属性之间的区别。
让我们回到本节的主题,并检查使用 @btime 宏获取数据帧列的操作速度有多快(回想第三章中提到的,我们需要在全局变量 puzzles 前面加上 $ 以获得正确的基准测试结果):
julia> using BenchmarkTools
julia> @btime $puzzles.Rating;
7.600 ns (0 allocations: 0 bytes)
操作非常快。它只需要几纳秒,因为以这种方式访问数据帧的列不需要复制数据。通过编写 puzzles.Rating,你可以得到由 puzzles 变量引用的相同数据。Julia 需要执行的唯一操作是从 :colindex 私有字段中检索信息,即 Rating 有列号 4,然后从 :columns 私有字段中提取它。
这种行为有明显的性能优势。然而,你可能会问,如果你想要获取向量的副本,该怎么办。这不仅仅是一个普通的问题。在实践中,你可能稍后想要修改它,而不改变原始向量的数据。
在 Julia 中复制对象的一种既定方法是调用它的 copy 函数,所以通过编写 copy(puzzles.Rating),你可以得到 puzzles 数据帧中存储的向量的副本。然而,当你比较 puzzles.Rating 和 copy(puzzles.Rating) 时,你会了解到它们是相等的:
julia> puzzles.Rating == copy(puzzles.Rating)
true
这表明使用 == 来测试两个向量是否相等是比较它们的内容,而不是它们的内存位置。是否有可能以某种方式比较向量,从而检查它们是否是相同的对象(在这个意义上,没有任何 Julia 程序能够区分它们)?确实有。你可以通过使用第七章中讨论的 === 比较来实现这一点:
julia> puzzles.Rating === copy(puzzles.Rating)
false
我们看到这两个对象不是同一个(尽管它们有相同的内容)。
如果我们使用 === 来比较 puzzles.Rating 和 puzzles.Rating,我们会得到 true,因为这次它们确实是同一个对象:
julia> puzzles.Rating === puzzles.Rating
true
另一方面,两个副本是不同的,正如预期的那样:
julia> copy(puzzles.Rating) === copy(puzzles.Rating)
false
你也可以在点(.)之后使用字符串字面量来获取数据框的列:
julia> puzzles."Rating"
2132989-element Vector{Int64}:
1765
1525
1102
⋮
980
1783
2481
这个操作的效果与写入 puzzles.Rating 相同。那么,你可能会问,为什么 puzzles."Rating" 有用。这种语法简化了处理数据框列名中的任何特殊字符(例如,空格)。然后双引号(")使得列名的开始和结束变得明确。将 puzzles."Rating" 而不是 puzzles.Rating 写入的一个小缺点是,它稍微慢一些,因为 Julia 需要在从数据框获取数据之前将字符串转换为 Symbol。然而,这个操作仍然很快(纳秒级)。
练习 8.1 使用 BenchmarkTools.jl 包,通过使用 puzzles ."Rating" 语法来测量从数据框获取列的性能。
8.3.3 通过数据框索引获取列
使用属性访问,如 puzzles.Rating,来获取数据框的列容易输入,但有一个缺点。如果列名存储在一个变量中,会怎样呢?
julia> col = "Rating"
"Rating"
你如何获取由 col 变量引用的 puzzles 数据框的列?以及你如何从数据框中通过编号而不是名称来获取列?这两个问题都通过使用索引语法得到解答。
数据框索引的一般形式如下:
data_frame_name[selected_rows, selected_columns]
如你所见,这与第四章中讨论的矩阵类似。在本章中,我们将讨论各种选项的接受值,包括 selected_rows 和 selected_columns,但在这个部分,我们专注于你应该如何从数据框中获取单个列。
要通过复制从数据框中获取列,使用冒号(:)作为行选择器,并使用字符串、Symbol 或数字作为列选择器。这里有四种等效的方法来使用复制从 puzzles 数据框中获取 Rating 列:
puzzles[:, "Rating"] ❶
puzzles[:, :Rating] ❷
puzzles[:, 4] ❸
puzzles[:, col] ❹
❶ 使用字符串传递列选择器
❷ 使用符号传递列选择器
❸ 使用整数传递列选择器
❹ 使用变量(在这种情况下存储字符串)传递列选择器
突出强调,在引用数据框的列时,您始终可以使用字符串或符号。为了方便用户,数据框将接受两者并将它们以相同的方式处理。选择对您更方便的样式。如果您担心性能,使用符号会稍微快一点,但使用字符串的时间增加是可以忽略不计的。
注意,在最后一个选择器 puzzles[:, col] 中,我们使用绑定到 "Rating" 字符串的 col 变量。允许这种选择是使用索引而不是属性访问的好处。
最后,您可能会问我是如何确定 Rating 是我们数据框中的第四列的。这很容易使用 columnindex 函数来检查:
julia> columnindex(puzzles, "Rating")
4
如果数据框中没有找到特定的列名,columnindex 函数将返回 0,就像这个例子一样:
julia> columnindex(puzzles, "Some fancy column name")
0
在 DataFrames.jl 中,列是从 1 开始编号的(就像在标准数组中一样),因此如果您从 columnindex 函数中获得 0 值,您知道这样的列名不存在于数据框中。
您还可以使用 hasproperty 函数来测试数据框是否包含特定的列名:
julia> hasproperty(puzzles, "Rating")
true
julia> hasproperty(puzzles, "Some fancy column name")
false
注意,在 columnindex 和 hasproperty 中,如果我们愿意,可以使用符号而不是字符串来传递列名。
通过复制从数据框中获取列比非复制操作更昂贵:
julia> @btime $puzzles[:, :Rating];
2.170 ms (2 allocations: 16.27 MiB)
在这种情况下,时间从 puzzles.Rating 选择器的纳秒增长到 puzzles[:, :Rating] 的毫秒。此外,使用的内存也更多。
要从数据框中获取列而不进行复制,请使用感叹号(!)作为行选择器,并使用字符串、符号或数字作为列选择器。以下是四种获取 puzzles 数据框中 Rating 列而不进行复制的等价方法,使用索引:
puzzles[!, "Rating"]
puzzles[!, :Rating]
puzzles[!, 4]
puzzles[!, col]
注意,编写 puzzles[!, "Rating"] 与编写 puzzles.Rating 等效。回想一下,如果您使用冒号(:)而不是感叹号(!)(例如:puzzles[:, "Rating"]),您将得到 Rating 列的副本。
在访问数据框的列时请谨慎使用非复制访问
在许多应用中,用户可能会被诱惑使用数据框列的非复制访问,例如编写 puzzles.Rating 或 puzzles[!, "Rating"]。这种方法有其优点,因为访问更快。然而,如果您对获得的向量进行修改,非复制访问有一个严重的缺点。DataFrames.jl 用户的经验表明,这种类型的访问有时会导致难以捕捉的故障。
因此,作为一个经验法则,始终以复制的方式访问数据框的列——即像 puzzles[:, "Rating"] 一样——除非您 100% 确定您不会修改列,或者您的操作需要非常快(例如,它在一个执行数百万次的循环中)。
8.3.4 在数据框的列中可视化存储的数据
现在你已经学会了如何从一个数据框中获取列,我们就可以创建所需的图表了。以下代码使用 Plots.jl 包中的直方图函数生成 Rating、RatingDeviation、Popularity 和 NbPlays 列的四个直方图:
julia> using Plots
julia> plot(histogram(puzzles.Rating; label="Rating"),
histogram(puzzles.RatingDeviation; label="RatingDeviation"),
histogram(puzzles.Popularity; label="Popularity"),
histogram(puzzles.NbPlays; label="NbPlays"))
你可以在图 8.2 中看到结果。所有这些变量都显著偏斜。我们将在第九章讨论如何处理这个问题,并分析拼图评分与流行度之间的关系。

图 8.2 在这些来自拼图数据框的 Rating、RatingDeviation、Popularity 和 NbPlays 列的直方图中,所有分析的变量都是偏斜的。
然而,在进入下一章之前,让我们作为一个练习,用另一种方式编写生成图表的代码:
julia> plot([histogram(puzzles[!, col]; label=col) for
col in ["Rating", "RatingDeviation",
"Popularity", "NbPlays"]]...)
代码末尾的三个点(...)是你在第四章中学到的散列操作。我们需要它,因为绘图函数期望我们将创建的直方图作为连续的位置参数传递。此代码展示了你如何利用在索引数据框时可以使用变量而不是显式的列名这一事实。请注意,在这种情况下,我使用了非复制访问数据(通过应用!作为行选择器),因为我确信我不会修改或存储提取的列(这些值仅用于生成图表)。
8.4 使用不同格式读取和写入数据框
在本章中,你已经学会了如何使用 Julia 读取和写入 CSV 文件。然而,在数据科学项目中,还使用了许多其他数据存储格式。当你在 Julia 中处理数据框时,你通常会想使用这些格式。
这里有一些按字母顺序排列的(在括号中,我给出了支持它们的 Julia 包的名称):Apache Arrow (Arrow.jl)、Apache Avro (Avro.jl)、Apache Parquet (Parquet.jl)、Microsoft Excel (XLSX.jl)、JSON (JSON3.jl)、MySQL (MySQL.jl)、PostgreSQL (LibPQ.jl)和 SQLite (SQLite.jl)。在本节中,我将向你展示 Arrow.jl 和 SQLite.jl 包。
Apache Arrow 格式是一种与语言无关的列式内存格式,旨在进行高效的分析操作。这种格式越来越受欢迎,因为它允许以几乎或没有成本在不同系统之间传输数据,无论使用哪种编程语言。除了这些优点之外,我选择这个格式来展示 Julia 如何以透明的方式处理存储在非原生内存格式中的数据。你可以在arrow.apache.org/了解更多关于这个标准的信息。
作为第二个例子,我们将使用 SQLite 数据库。在我看来,这是最容易设置和使用的数据库之一。因此,它也是实践中使用最广泛的数据库之一;据报道,有超过一千兆(10¹²)个 SQLite 数据库正在使用中(www.sqlite.org/mostdeployed.html)。
对于这两种数据格式,我将为您提供一个关于如何保存和加载我们在本章中使用的 puzzles 数据帧的最小介绍。
8.4.1 Apache Arrow
我们从以 Apache Arrow 格式保存数据帧开始。如第一章所述,它由 Apache Parquet、PySpark 和 Dask 等流行框架支持。这项任务相对简单;只需使用 Arrow.write 函数,传递要保存的文件名和数据帧:
julia> using Arrow
julia> Arrow.write("puzzles.arrow", puzzles)
"puzzles.arrow"
一个更有趣的过程与读取存储在 Apache Arrow 格式中的数据有关。您首先需要创建一个 Arrow.Table 对象,然后将它传递给 DataFrame 构造函数。在代码中,我们检查我们读取回的对象是否与原始 puzzles 数据帧相同:
julia> arrow_table = Arrow.Table("puzzles.arrow") ❶
Arrow.Table with 2329344 rows, 9 columns, and schema:
:PuzzleId String
:FEN String
:Moves String
:Rating Int64
:RatingDeviation Int64
:Popularity Int64
:NbPlays Int64
:Themes String
:GameUrl String
julia> puzzles_arrow = DataFrame(arrow_table); ❷
julia> puzzles_arrow == puzzles ❸
true
❶ 创建一个包含对磁盘上源数据引用的 Arrow.Table 对象
❷ 从 Arrow.Table 构建 DataFrame
❸ 检查我们创建的数据帧与使用的源数据帧具有相同的内容
Arrow.Table 对象的显著特点是它存储的列使用 Apache Arrow 格式。同样重要的是,Arrow.Table 中的列是原始 arrow 内存的视图。
这有一个显著的优势。当创建 Arrow.Table 对象时,操作系统不会同时将整个文件内容加载到 RAM 中。相反,当请求文件的不同区域时,文件的部分内容会被部分交换到 RAM 中。这允许支持处理大于可用 RAM 的 Apache Arrow 数据。此外,如果您只需要处理源表的一部分,读取过程会更快,因为您只需获取所需的数据。
然而,这种设计有一个缺点,因为这意味着具有 Apache Arrow 数据格式的列是只读的。以下是一个例子:
julia> puzzles_arrow.PuzzleId
2329344-element Arrow.List{String, Int32, Vector{UInt8}}:
"00008"
"0000D"
⋮
"zzzco"
"zzzhI"
julia> puzzles_arrow.PuzzleId[1] = "newID"
ERROR: setindex! not defined for Arrow.List{String, Int32, Vector{UInt8}}
注意,puzzles_arrow.PuzzleId 列具有非标准的 Arrow.List 类型,而不是例如 Vector 类型。这种非标准向量类型是只读的。我们通过尝试更改此类向量的一个元素来检查这一点,并得到一个错误。
在许多应用程序中,如果源数据帧的列是只读的,通常没有问题,因为我们可能只想从它们中读取数据。然而,有时您可能希望修改从 Apache Arrow 源创建的数据帧中存储的向量。
在这种情况下,只需复制数据帧。通过这样做,您将在 RAM 中实现 Apache Arrow 列,并将它们的类型更改为可变的标准 Julia 类型。以下是一个例子:
julia> puzzles_arrow = copy(puzzles_arrow);
julia> puzzles_arrow.PuzzleId
2329344-element Vector{String}:
"00008"
"0000D"
⋮
"zzzco"
"zzzhI"
在执行数据帧的复制操作后,:PuzzleId 列现在具有标准的 Vector 类型。
8.4.2 SQLite
对于 Apache Arrow 数据,我们首先将在磁盘上创建一个 SQLite 数据库。接下来,我们将存储 puzzles 数据帧到其中。最后,我们将使用 SQL SELECT 查询读取它。
首先,我们创建一个由磁盘上的文件支持的 SQLite 数据库。使用 SQLite.DB 函数,传递一个文件名作为参数:
julia> using SQLite
julia> db = SQLite.DB("puzzles.db")
SQLite.DB("puzzles.db")
接下来,我们使用 SQLite.load!函数将谜题数据框存储在其中。我们传递三个位置参数:要存储在数据库中的表、我们想要存储数据库的连接以及目标数据库中的表名:
julia> SQLite.load!(puzzles, db, "puzzles")
"puzzles"
让我们检查一下是否已成功在我们的数据库中创建了一个表。我们首先使用 SQLite.tables 函数列出数据库中存储的所有表,然后使用 SQLite.columns 函数获取有关给定表中存储的列的更详细信息:
julia> SQLite.tables(db)
1-element Vector{SQLite.DBTable}:
SQLite.DBTable("puzzles", Tables.Schema:
:PuzzleId Union{Missing, String}
:FEN Union{Missing, String}
:Moves Union{Missing, String}
:Rating Union{Missing, Int64}
:RatingDeviation Union{Missing, Int64}
:Popularity Union{Missing, Int64}
:NbPlays Union{Missing, Int64}
:Themes Union{Missing, String}
:GameUrl Union{Missing, String})
julia> SQLite.columns(db, "puzzles")
(cid = [0, 1, 2, 3, 4, 5, 6, 7, 8],
name = ["PuzzleId", "FEN", "Moves", "Rating", "RatingDeviation",
"Popularity", "NbPlays", "Themes", "GameUrl"],
type = ["TEXT", "TEXT", "TEXT", "INT", "INT",
"INT", "INT", "TEXT", "TEXT"],
notnull = [1, 1, 1, 1, 1, 1, 1, 1, 1],
dflt_value = [missing, missing, missing, missing, missing,
missing, missing, missing, missing],
pk = [0, 0, 0, 0, 0, 0, 0, 0, 0])
我们看到现在数据库中有一个谜题表。关于此表列的元数据与源谜题数据框的结构相匹配。
最后,我们将谜题表读回到一个数据框中。作为一个重要的第一步,我们需要创建一个查询。我们使用 DBInterface.execute 函数,向其传递一个数据库连接和一个包含我们想要运行的 SQL 查询的字符串。重要的是,这个操作是惰性的,不会实际化查询。数据只在需要时才被检索。在我们的例子中,我们通过使用查询的结果创建一个数据框来实现这种实际化。
此外,请注意我们使用的是一个通用的执行函数,它不是 SQLite 特有的。该函数定义在由 SQLite.jl 自动加载的接口包 DBInterface.jl 中。如果我们使用另一个数据库后端,我们也会使用 DBInterface.execute 来以相同的方式运行 SQL 查询。在创建数据框之后,我们检查获得的结果与原始谜题数据框相同:
julia> query = DBInterface.execute(db, "SELECT * FROM puzzles")
SQLite.Query(SQLite.Stmt(SQLite.DB("puzzles.db"), 7),
Base.RefValue{Int32}(100), [:PuzzleId, :FEN, :Moves, :Rating,
:RatingDeviation, :Popularity, :NbPlays, :Themes, :GameUrl],
Type[Union{Missing, String}, Union{Missing, String},
Union{Missing, String}, Union{Missing, Int64}, Union{Missing, Int64},
Union{Missing, Int64}, Union{Missing, Int64}, Union{Missing, String},
Union{Missing, String}], Dict(:NbPlays => 7, :Themes => 8, :GameUrl => 9,
:Moves => 3, :RatingDeviation => 5, :FEN => 2, :Rating => 4,
:PuzzleId => 1, :Popularity => 6), Base.RefValue{Int64}(0))
julia> puzzles_db = DataFrame(query);
julia> puzzles_db == puzzles
true
这次,与 Apache Arrow 的情况不同,数据框的列是标准的 Julia 向量。让我们检查一下:PuzzleId 列:
julia> puzzles_db.PuzzleId
2329344-element Vector{String}:
"00008"
"0000D"
⋮
"zzzco"
"zzzhI"
使用完 SQLite 数据库后,我们需要关闭它:
julia> close(db)
要了解如何使用本节中讨论的 Arrow.jl 和 SQLite.jl 包,请访问它们的相应存储库:github.com/apache/arrow-julia 和 github.com/JuliaDatabases/SQLite.jl。
摘要
-
DataFrames.jl 是一个允许你在 Julia 中处理表格数据的包。它定义的最重要类型是 DataFrame,其行通常表示观测值,而列通常表示这些观测值的特征。
-
您可以使用 CodecBzip2.jl 包解压 bzip2 存档。在 Julia 中以及对于其他压缩格式,也有类似的功能,因为在实际应用中,您通常会需要处理压缩数据。
-
CSV.jl 包中的 CSV.read 函数可以用来将存储在 CSV 文件中的数据读入 DataFrame。同样,CSV.write 函数可以用来将表格数据保存到 CSV 文件。CSV 格式是最受欢迎的人可读格式之一,您在数据分析时可能会经常使用它。
-
您可以使用
describe函数来获取数据框的摘要信息。通过这种方式,您可以快速检查数据框中存储的数据是否符合您的预期。 -
nrow和ncol函数为您提供有关数据框行数和列数的信息。由于这些函数返回一个值,因此它们在编写操作数据框对象的代码时经常被使用。 -
names函数可以用来获取数据框中列名的列表;它还接受一个列选择器参数,允许您传递指定要获取的列名的条件。当您处理具有数千列的非常宽的数据框时,此功能特别有用。 -
在内部,
DataFrame对象按列存储数据。数据框的每一列都是一个向量。这确保了从数据框中提取列非常快速。 -
您可以通过使用属性访问语法(例如,
puzzles.Rating返回Rating列)从数据框中获取列。这是最常执行的操作之一,因为它便于输入和阅读。 -
当引用数据框的列时,您可以使用字符串或符号;因此,
puzzles."Rating"和puzzles.Rating都是有效的。如果您的列名包含 Julia 中标识符不允许的字符(例如,空格),使用字符串特别有用。 -
您可以使用
Plots.jl包中的histogram函数来绘制数据的直方图。这是一种检查数据分布的有用方法。 -
Arrow.jl包允许您使用 Apache Arrow 格式存储的数据。当您想要在不同数据分析生态系统之间交换数据或处理太大而无法放入 RAM 的数据时,这通常很有用。 -
SQLite.jl包提供了一个到 SQLite 数据库引擎的接口。SQLite 数据库是存储、共享和归档您数据最受欢迎的格式之一。
9 从数据框获取数据
本章涵盖了
-
子集数据框的行
-
选择数据框的列
-
创建局部线性回归(LOESS)模型
-
可视化 LOESS 预测
在第八章中,您学习了使用 DataFrames.jl 包在 Julia 中处理数据框的基本原则,并且我们开始分析 Lichess 棋盘数据。回想一下,我们的目标是确定谜题难度与受欢迎程度之间的关系。
在第 8.3 节中,我们通过得出结论,我们希望在最终分析(如图 9.1 所示,我重现了第八章中使用的直方图)之前清理原始数据来停止我们的调查,即原始数据具有显著偏斜。数据清理的最简单形式是删除不需要的观测值。因此,在本章中,您将学习如何通过子集数据框的行和选择列来获取数据。
本章的目标是检查谜题难度与用户喜爱程度之间的关系。为了进行这项分析,我们将采取以下步骤:
-
将数据集子集化,仅关注我们想要稍后分析的列和行。
-
在数据框中聚合关于谜题难度与受欢迎程度之间关系的数据并绘制它。
-
建立局部线性回归(LOESS)模型以获取关于数据中存在的关系的更好总结信息。
通过这次分析,您将在学习本章后获得的关键技能是学习各种进入数据框的方法。
数据框索引——选择其部分列或子集行——是实践中最常需要的操作之一。因此,学习如何在数据框中进行索引是您使用 DataFrames.jl 进行数据框之旅的一个良好起点。
为了实现这一目标,同时向您展示如何分析 Lichess 谜题,我按照以下结构组织了本章:
-
在第 9.1 节中,我深入讨论了通过子集数据框的行和/或选择列来索引数据框的多种方法。
-
为了加强您的学习,在第 9.2 节中,我不介绍任何新概念,而是展示如何在第八章和第 9.1 节中介绍的知识在更复杂的场景中结合起来。在本节的最后一步,我们将构建一个 LOESS 模型来理解 Lichess 数据库中谜题难度与受欢迎程度之间的关系。
9.1 高级数据框索引
在本节中,您将学习如何在数据框中执行列选择和行子集。这是处理数据框时最常见的操作之一。
图 8.2,在此处作为方便您查看的图 9.1 重现,显示我们正在处理的数据具有显著偏斜。
在继续分析评分和受欢迎程度列之间的关系之前,让我们将以下条件应用到我们的数据框中,以创建一个我们将用于后续分析的新的数据框:
-
我们只想保留 Rating 和 Popularity 列,因为这些是我们分析所需的唯一列。
-
我们希望删除代表我们不希望包含在分析中的谜题的行。
现在我们来讨论谜题必须满足的条件才能被包含在我们的分析中。

图 9.1 从 puzzles 数据框的 Rating、RatingDeviation、Popularity 和 NbPlays 列的直方图中可以看出,所有分析变量都是偏斜的。
首先,我们希望保留被玩得足够的谜题。因此,我们关注那些 NbPlays 列中的播放次数大于该列中位数的谜题。这个条件将消除 50%的谜题。这样,我们去除那些玩得很少的谜题,因为它们可能没有稳定的评分或流行度值。
其次,我们希望删除难度评分低或非常高的谜题。这会去除简单的谜题,这些谜题很可能是经验不足的玩家评估的,以及对于典型玩家来说可能没有足够经验来公正评估的非常难的谜题。我们将考虑评分低于 1500 的谜题为过于简单,不适合包含。我选择 1500 作为阈值,因为这是任何谜题的起始评分。在列表 8.2 中,你可以看到评分的中位数和平均值大约是 1500。为了表示一个非常高的评分,我们使用第 99 百分位数(我们将删除最难的 1%的谜题)。
本节组织如下。在 9.1.1 节中,我将向你展示如何执行我们想要分析 Lichess 谜题数据的确切操作。这样,你将了解这些操作是如何工作的。接下来,在 9.1.2 小节中,我将提供一个允许的列选择器的完整列表,而在 9.1.3 小节中,我们将讨论允许的行子集选项的完整列表。
注意:在处理本章的代码之前,请按照第 8.1 节的说明操作,以确保你的工作目录中存在 puzzles.csv 文件。
在我们开始之前,我们需要加载库并从我们在第八章中创建的 puzzles.csv 文件创建 puzzles 数据框对象:
julia> using DataFrames
julia> using CSV
julia> using Plots
julia> puzzles = CSV.read("puzzles.csv", DataFrame);
9.1.1 获取简化的 puzzles 数据框
在本节中,你将学习如何选择数据框的列和子集其行。为了获取我们的处理后的数据框,我们需要定义一个合适的列选择器和行选择器。
我们从列选择器开始,因为在这种情况下,它是简单的。我们可以传递一个列名向量,例如:["Rating", "Popularity"]。或者,我们也可以传递列名作为符号,[:Rating, :Popularity],或者,例如,作为整数[4, 6]。(记住,你可以通过使用 columnindex 函数轻松检查列号。)在 9.1.2 节中,你将了解 DataFrames.jl 提供的更多列选择器选项。
为了定义适当的行子集操作,我们将使用 指示向量。这个向量必须与我们数据框中的行数一样多,并且必须包含布尔值。指示向量中对应于真值的行将被保留,而假值将被删除。
首先,我们使用 Statistics 模块中的 median 函数创建一个表示播放次数少于中值的行的指示向量:
julia> using Statistics
julia> plays_lo = median(puzzles.NbPlays)
246.0
julia> puzzles.NbPlays .> plays_lo
2132989-element BitVector:
1
1
1
⋮
1
1
0
注意,当我们用 .> 比较 NbPlays 列与计算出的中值的标量值时,我们使用了广播。如果我们省略点(.),我们会得到一个错误:
julia> puzzles.NbPlays > plays_lo
ERROR: MethodError: no method matching isless(::Float64, ::Vector{Int64})
以类似的方式,让我们创建一个表示评分在 1500 到第 99 个百分位数之间的谜题的指示向量。对于第二种条件,我们使用 Statistics 模块中的 quantile 函数:
julia> rating_lo = 1500
1500
julia> rating_hi = quantile(puzzles.Rating, 0.99)
2658.0
julia> rating_lo .< puzzles.Rating .< rating_hi
2132989-element BitVector:
1
1
0
⋮
0
1
1
我们再次使用了广播来得到期望的结果。最后,让我们使用广播的 && 操作符结合这两个条件:
julia> row_selector = (puzzles.NbPlays .> plays_lo) .&&
(rating_lo .< puzzles.Rating .< rating_hi)
2132989-element BitVector:
1
1
0
⋮
0
1
0
在这个表达式中,我可以省略括号,但我的个人偏好是始终在工作于复杂条件时明确显示操作应该如何分组。
让我们检查我们选择了多少行。我们可以使用 sum 或 count 函数:
julia> sum(row_selector)
513357
julia> count(row_selector)
513357
sum 函数和 count 函数之间的区别在于,count 要求传递给它的数据是布尔值,并计算真值的数量,而 sum 可以处理任何对加法有意义的定义的数据。由于在 Julia 中,布尔值被视为数字,正如你在第二章中学到的,你可以将它们相加。在这种加法中,true 被认为是 1,false 被认为是 0。
现在我们已经准备好在下一个列表中创建我们所需的数据框了。我们将它称为 good。
列表 9.1 通过索引选择数据框的行和列
julia> good = puzzles[row_selector, ["Rating", "Popularity"]]
513357×2 DataFrame
Row │ Rating Popularity
│ Int64 Int64
────────┼────────────────────
1 │ 1765 93
2 │ 1525 97
3 │ 1560 88
: │ : :
513356 │ 2069 92
513357 │ 1783 90
513352 rows omitted
我们可以看到,good 数据框有 513,357 行和两列,正如预期的那样。让我们创建所选列的直方图(图 9.2),看看它们现在是否有更好的分布:
julia> plot(histogram(good.Rating; label="Rating"),
histogram(good.Popularity; label="Popularity"))

图 9.2 在 good 数据框的列 Rating 和 Popularity 的这些直方图中,两个变量的分布都符合预期,我们将在进一步的分析中使用这些数据。
评分分布现在大致呈下降趋势;我们比简单谜题有更少的困难谜题。对于流行度的分布,我们没有在 -100 和 100 处出现峰值,如图 8.2 所示,这很可能是由于玩得很少的谜题造成的。请将此假设作为练习进行检查。
练习 9.1 在两种条件下计算 NbPlays 列的摘要统计量。在第一种情况下,仅选择流行度为 100 的谜题,在第二种情况下,选择流行度为 -100 的谜题。要计算向量的摘要统计量,请使用 StatsBase.jl 包中的 summarystats 函数。
9.1.2 允许的列选择器概述
在实践中,你可能希望使用不同的条件来选择数据框的列——例如,保留所有你不想保留的列,或者保留所有名称与特定模式匹配的列。在本节中,你将了解到 DataFrames.jl 提供了一套丰富的列选择器,允许你轻松完成此类任务。
你在第 8.3 节中学到,传递一个字符串、一个 Symbol 或一个整数作为列选择器可以从数据框中提取一个列。这三个列选择器被称为单列选择器。获得的结果类型取决于所使用的行选择器。如果你使用单个整数,你会得到数据框单元格中存储的值:
julia> puzzles[1, "Rating"]
1765
如果行选择器选择了多行,你会得到一个向量。让我们重复一下你在第 8.3 节中已经看到过的例子:
julia> puzzles[:, "Rating"]
2132989-element Vector{Int64}:
1765
1525
1102
⋮
980
1783
2481
我们将要讨论的所有其他选择器都选择多列。获得的结果类型再次取决于所使用的行子集值。如果你选择单行,你会得到一个名为 DataFrameRow 的对象:
julia> row1 = puzzles[1, ["Rating", "Popularity"]]
DataFrameRow
Row │ Rating Popularity
│ Int64 Int64
─────┼────────────────────
1 │ 1765 93
你可以将 DataFrameRow 视为一个包含所选单元格的 NamedTuple。唯一的区别是 DataFrameRow 保留了一个指向它所来自的数据框的链接。技术上,它是一个视图。因此,当数据框更新时,它会在 DataFrameRow 中反映出来。相反,如果你更新 DataFrameRow,底层数据框也会更新(我们将在第十二章详细讨论数据框的突变)。
一些用户可能会惊讶,在这种情况下使用常规索引会创建一个视图。然而,经过一番激烈的辩论后,这个设计选择被做出,以确保获取数据框的行是一个快速操作,因为通常你会在循环中挑选数据框的许多连续行,并且只从它们中读取数据。
目前为止,正如我所说的,你可以将 DataFrameRow 视为一个一维对象。因此,你可以像从数据框中获取数据一样从它那里获取数据,但如果你使用索引,只需使用单个索引。以下是从 row1 对象获取评分值的方法:
julia> row1["Rating"]
1765
julia> row1[:Rating]
1765
julia> row1[1]
1765
julia> row1.Rating
1765
julia> row1."Rating"
1765
另一方面,如果你选择多行和多列,你会得到一个 DataFrame。你已经在列表 9.1 中看到了这种选择类型:
julia> good = puzzles[row_selector, ["Rating", "Popularity"]]
513357×2 DataFrame
Row │ Rating Popularity
│ Int64 Int64
────────┼────────────────────
1 │ 1765 93
2 │ 1525 97
3 │ 1560 88
: │ : :
513356 │ 2069 92
513357 │ 1783 90
513352 rows omitted
表 9.1 总结了数据框索引的可能输出类型。
表 9.1 数据框索引的输出类型,取决于行子集值和列选择器
| 单列选择器 | 多列选择器 | |
|---|---|---|
| 单行子集 | good[1, "Rating"]单元格中存储的值 | good[1, :]DataFrameRow |
| 多行子集 | good[:, "Rating"]向量 | good[:, :]DataFrame |
现在你已经知道了在给定不同的列选择器时可以期望的输出类型,我们就可以深入探讨可用的多列选择器了。我们有很多这样的选择器,因为存在许多选择列的规则。我将逐一列举它们,并附上例子,参考我们的 puzzles 数据框。
我首先会向你展示可用的选项列表,这样你就可以简要地参考它们。接下来,我将解释如何使用 names 函数检查每个选项所选择的列,我们从列表开始:
-
一个字符串、符号或整数值的向量——你已经在第 8.3 节中看到了这种风格:["Rating", "Popularity"], [:Rating, :Popularity], [4, 6]。
-
一个长度等于数据框列数的布尔值向量——在这里,要选择 Rating 和 Popularity 列,一个合适的向量如下(注意它长度为 9,并在第 4 位和第 6 位有 true 值):
julia> [false, false, false, true, false, true, false, false, false]
9-element Vector{Bool}:
0
0
0
1
0
1
0
0
0
-
正则表达式——这会选择与传递的表达式匹配的列(我们在第六章讨论了正则表达式)。例如,传递一个 r"Rating" 正则表达式将选择 Rating 和 RatingDeviation 列。
-
一个 Not 表达式——这会否定传递的选择器。例如,Not([4, 6]) 将选择除了第 4 列和第 6 列之外的所有列;同样,Not(r"Rating") 将选择除了匹配 r"Rating" 正则表达式的 Rating 和 RatingDeviation 列之外的所有列。
-
Between 表达式——一个例子是 Between("Rating", "Popularity"),它从 Rating 开始并结束于 Popularity 的连续列,因此在我们的情况下,它将是 Rating、RatingDeviation 和 Popularity。
-
冒号(:)或 All() 选择器——这会选择所有列。
-
Cols 选择器——这有两种形式。在第一种形式中,你可以将多个选择器作为参数传递并选择它们的并集;例如,Cols(r"Rating", "NbPlays") 将选择 Rating、RatingDeviation 和 NbPlays 列。在第二种形式中,你将函数作为参数传递给 Cols;然后这个函数应该接受一个字符串,它是列的名称,并返回一个布尔值。结果,你将得到一个列表,其中包含传递的函数返回 true 的列。例如,如果你使用 Cols(startswith ("P")) 选择器,你会得到 PuzzleId 和 Popularity 列,因为这些是唯一以 P 开头的 puzzles 数据框中的列名。
这非常累人。幸运的是,正如我暗示的,有一个简单的方法来测试所有这些例子。
你还记得在第 8.2 节中讨论的 names 函数吗?它返回数据框中存储的列名。通常你需要从数据框中选择列名而不执行数据框索引。names 函数的好处是它可以接受任何列选择器作为其第二个参数,并将返回所选列的名称。让我们尝试使用 names 函数与前面列表中的所有示例:
julia> names(puzzles, ["Rating", "Popularity"])
2-element Vector{String}:
"Rating"
"Popularity"
julia> names(puzzles, [:Rating, :Popularity])
2-element Vector{String}:
"Rating"
"Popularity"
julia> names(puzzles, [4, 6])
2-element Vector{String}:
"Rating"
"Popularity"
julia> names(puzzles,
[false, false, false, true, false, true, false, false, false])
2-element Vector{String}:
"Rating"
"Popularity"
julia> names(puzzles, r"Rating")
2-element Vector{String}:
"Rating"
"RatingDeviation"
julia> names(puzzles, Not([4, 6]))
7-element Vector{String}:
"PuzzleId"
"FEN"
"Moves"
"RatingDeviation"
"NbPlays"
"Themes"
"GameUrl"
julia> names(puzzles, Not(r"Rating"))
7-element Vector{String}:
"PuzzleId"
"FEN"
"Moves"
"Popularity"
"NbPlays"
"Themes"
"GameUrl"
julia> names(puzzles, Between("Rating", "Popularity"))
3-element Vector{String}:
"Rating"
"RatingDeviation"
"Popularity"
julia> names(puzzles, :)
9-element Vector{String}:
"PuzzleId"
"FEN"
"Moves"
"Rating"
"RatingDeviation"
"Popularity"
"NbPlays"
"Themes"
"GameUrl"
julia> names(puzzles, All())
9-element Vector{String}:
"PuzzleId"
"FEN"
"Moves"
"Rating"
"RatingDeviation"
"Popularity"
"NbPlays"
"Themes"
"GameUrl"
julia> names(puzzles, Cols(r"Rating", "NbPlays"))
3-element Vector{String}:
"Rating"
"RatingDeviation"
"NbPlays"
julia> names(puzzles, Cols(startswith("P")))
2-element Vector{String}:
"PuzzleId"
"Popularity"
这并不是 names 函数所拥有的全部功能。
首先,你不必写 names(puzzles, Cols(startswith("P"))),你可以省略 Cols 包装器。调用 names(puzzles, startswith("P")),其中你传递一个接受字符串并返回布尔值的函数,将产生相同的结果。
最后一个特性是,你可以将类型作为 names 函数的第二个参数传递。你将得到元素类型是传递类型子类型的列。例如,要获取存储在 puzzles 数据框中所有实数列,你可以编写以下内容:
julia> names(puzzles, Real)
4-element Vector{String}:
"Rating"
"RatingDeviation"
"Popularity"
"NbPlays"
要获取所有包含字符串的列,请编写以下内容:
julia> names(puzzles, AbstractString)
5-element Vector{String}:
"PuzzleId"
"FEN"
"Moves"
"Themes"
"GameUrl"
注意,names 中最后两种接受的形式(传递函数和传递类型)在索引中不被接受。因此,要从 puzzles 数据框中选择所有存储实数的列,请编写 puzzles[:, names(puzzles, Real)]。
现在,你可以在指尖上拥有灵活选择数据框列的所有功能。我们可以继续到行选择器,它们稍微简单一些。
9.1.3 允许的行子集值概述
在本节中,你将学习执行数据框行子集的选项。在第 9.1.2 小节中,我们讨论了传递单个整数作为行子集值。如果你使用单列选择器,你将得到单个单元格的值;如果你使用多列选择器,你将得到 DataFrameRow。
当你选择多行时,你会得到一个向量(当选择单个列时)或数据框(当选择多个列时)。哪些多行选择器是被允许的?以下是一个完整的列表:
-
整数向量—例如,[1, 2, 3]将选择与传入的数字对应的行。
-
布尔值向量—其长度必须等于数据框中的行数,在结果中,你将得到向量包含 true 的行。你在第 8.3 节中看到了这个选择器;例如,在列表 9.1 中的表达式 puzzles[row_selector, ["Rating", "Popularity"]]中,row_selector 是一个布尔向量。
-
非 表达式—这与列的工作方式相同。编写 Not([1, 2, 3])将选择除了第 1 行、第 2 行和第 3 行之外的所有行。
-
冒号 (😃—这会选择数据框中的所有行并进行复制。
-
感叹号 (!)—这会从数据框中选择所有行而不进行复制(记住第 8.2 节中的警告,你应该小心使用此选项,因为它可能导致难以发现的错误)。
首先,让我们比较整数、布尔和 Not 选择器在一个小数据框上的效果。在示例中,我们首先创建名为:id 的单列 df_small 数据框,其值在 1 到 4 的范围内(我们将在第十章中详细讨论创建数据框的这种方法和其他方法)。接下来,我们使用各种行选择器对这个数据框进行子集化:
julia> df_small = DataFrame(id=1:4)
4×1 DataFrame
Row │ id
│ Int64
─────┼───────
1 │ 1
2 │ 2
3 │ 3
4 │ 4
julia> df_small[[1, 3], :]
2×1 DataFrame
Row │ id
│ Int64
─────┼───────
1 │ 1
2 │ 3
julia> df_small[[true, false, true, false], :]
2×1 DataFrame
Row │ id
│ Int64
─────┼───────
1 │ 1
2 │ 3
julia> df_small[Not([2, 4]), :]
2×1 DataFrame
Row │ id
│ Int64
─────┼───────
1 │ 1
2 │ 3
julia> df_small[Not([false, true, false, true]), :]
2×1 DataFrame
Row │ id
│ Int64
─────┼───────
1 │ 1
2 │ 3
在示例中,所有索引操作都保留了 df_small 数据框的第 1 行和第 3 行。
接下来,让我们看看比较:和!行选择器的示例。让我们比较以下选择操作:
julia> df1 = puzzles[:, ["Rating", "Popularity"]];
julia> df2 = puzzles[!, ["Rating", "Popularity"]];
df1 和 df2 都从 puzzles 数据框中选择所有行和两列。我们可以检查它们是否存储相同的数据:
julia> df1 == df2
true
虽然 df1 和 df2 具有相同的内容,但它们并不相同。区别在于 df1 复制了 Rating 和 Popularity 列,而 df2 则重用了来自 puzzles 数据框的 Rating 和 Popularity 列。我们可以通过使用===比较来轻松检查它:
julia> df1.Rating === puzzles.Rating
false
julia> df1.Popularity === puzzles.Popularity
false
julia> df2.Rating === puzzles.Rating
true
julia> df2.Popularity === puzzles.Popularity
true
因此,稍后修改 df2 数据框可能会影响存储在 puzzles 数据框中的数据,这是不安全的。再次强调,使用!而不是:的好处是速度和内存消耗,如下面的基准测试所示:
julia> using BenchmarkTools
julia> @btime $puzzles[:, ["Rating", "Popularity"]];
4.370 ms (27 allocations: 32.55 MiB)
julia> @btime $puzzles[!, ["Rating", "Popularity"]];
864.583 ns (21 allocations: 1.70 KiB)
作为总结,我将再次总结可用的选项。请记住,传递一个整数,如 1,可以选择单个行或列,而传递一个包含它的向量,如[1],可以选择多个行或列(在这种情况下恰好是 1)。因此,我们有四种索引到数据框的方法,它们在获得的结果上有所不同:
-
对于行和列索引都传递单元素选择器,返回数据框的单个单元格内容:
-
julia> puzzles[1, 1] "00008" -
传递一个多行子集值和一个单列选择器返回一个向量:
-
julia> puzzles[[1], 1] 1-element Vector{String7}: "00008" -
传递一个单行子集值和多列选择器返回一个 DataFrameRow:
-
julia> puzzles[1, [1]] DataFrameRow Row │ PuzzleId │ String7 ─────┼────────── 1 │ 00008 -
传递一个多行子集值和多列选择器返回一个 DataFrame:
-
julia> puzzles[[1], [1]] 1×1 DataFrame Row │ PuzzleId │ String7 ─────┼────────── 1 │ 00008
数据框行名
你可能已经注意到 DataFrames.jl 不支持为你的 DataFrame 对象提供行名。引用数据框的行的唯一方式是通过其编号。
然而,很容易向你的数据框中添加一个存储行名的列。行名常在其他生态系统中用于提供一种快速行查找的方式。在第 11、12 和 13 章中,你将了解到 DataFrames.jl 通过使用基于 groupby 函数的替代方法来提供这种功能。
9.1.4 创建数据框对象的视图
在第四章中,你了解到可以使用@view 宏创建避免复制数据的数组视图。在 DataFrames.jl 中也支持相同的机制。如果你将任何索引表达式传递给@view 宏,你将得到一个视图。
创建视图的好处是,通常它比第 9.1.3 节中讨论的标准索引更快,使用的内存更少。然而,这种好处是有代价的。视图与父对象共享数据,这可能导致代码中难以捕捉的 bug,尤其是如果你修改了视图引用的数据。
你有四种方法来创建数据框的视图,这取决于行和列选择器是否选择一个或多个行,如第 9.1.3 小节所述:
-
对于行和列索引都传递单元素选择器,返回数据框单个单元格内容的视图(技术上,如你所见,它被认为是一个零维对象;如果你想了解更多关于这些的信息,请参阅 Julia 手册的“常见问题解答”部分,网址为
mng.bz/9VKq): -
julia> @view puzzles[1, 1] 0-dimensional view(::Vector{String7}, 1) with eltype String7: "00008" -
传递一个多行选择器和单列选择器返回一个向量的视图:
-
julia> @view puzzles[[1], 1] 1-element view(::Vector{String7}, [1]) with eltype String7: "00008" -
通过单行选择器和多列选择器返回一个 DataFrameRow(因此与 puzzles[1, [1]]的正常索引没有区别,因为正常索引已经产生了一个视图;参见 9.1.2 小节中的讨论):
-
puzzles[1, [1]] DataFrameRow Row │ PuzzleId │ String7 ─────┼────────── 1 │ 00008 -
通过多行和多列选择器返回一个子数据框:
-
julia> @view puzzles[[1], [1]] 1×1 SubDataFrame Row │ PuzzleId │ String7 ─────┼────────── 1 │ 00008
在这些选项中,最常用的是创建一个子数据框。当你想要节省内存和时间时,你会使用数据框的视图,并且接受你的结果对象将与其父对象共享内存。
例如,让我们比较一下我们在 9.1 小节中列出的操作 puzzles[row_selector, ["Rating", "Popularity"]]的性能与创建视图的相同操作:
julia> @btime $puzzles[$row_selector, ["Rating", "Popularity"]];
4.606 ms (22 allocations: 11.75 MiB)
julia> @btime @view $puzzles[$row_selector, ["Rating", "Popularity"]];
1.109 ms (12 allocations: 3.92 MiB)
创建视图更快且占用更少的内存。我们在创建数据框视图时看到的最大分配是为存储所选行和列的信息。你可以使用 parentindices 函数检索由子数据框选择的源数据框的索引:
julia> parentindices(@view puzzles[row_selector, ["Rating", "Popularity"]])
([1, 2, 5, 8 ... 2132982, 2132983, 2132984, 2132988], [4, 6])
什么是数据框?
你现在知道 DataFrames.jl 定义了 DataFrame 和子数据框类型。这两个类型有一个共同的超类型:AbstractDataFrame。AbstractDataFrame 在 DataFrames.jl 中表示数据框的一般概念,与其在内存中的底层表示无关。
DataFrames.jl 中的大多数函数都适用于 AbstractDataFrame 对象,因此在这些情况下它们接受 DataFrame 和子数据框类型。在这本书中,我写的是我们使用数据框。例如,我们在这章中使用的索引对所有数据框都按相同的方式工作。
然而,在某些情况下,我们与具体类型一起工作是很重要的。例如,DataFrame 构造函数始终返回一个 DataFrame。此外,在第十一章中,我们将讨论通过使用 push!函数就地添加行到 DataFrame。由于子数据框对象是视图,因此不支持此操作。
9.2 分析谜题难度与流行度之间的关系
如章节介绍中所承诺的,在本节中,我们将使用你在更复杂的环境中获得的技能来理解谜题难度与流行度之间的关系。我们将分两步进行。在 9.2.1 节中,我们将根据评分计算谜题的平均流行度。接下来,在 9.2.2 节中,我们将对数据进行 LOESS 回归拟合。
9.2.1 通过评分计算谜题的平均流行度
在本节中,你将学习如何使用 Base Julia 的功能在数据框中聚合数据。聚合是数据分析中最常见的操作之一。
我们将使用在第 9.1 节中创建的好数据框。本节中使用的这种方法旨在向您展示如何使用数据框的索引。然而,这并不是执行分析的最有效方法。在本章末尾,我将向您展示完成所需操作更快但需要学习与 groupby 函数相关的 DataFrames.jl 包的高级功能的代码(这些将在第 11、12 和 13 章中讨论)。首先,让我们回顾一下好数据框的内容:
julia> describe(good)
2×7 DataFrame
Row │ variable mean min median max nmissing eltype
│ Symbol Float64 Int64 Float64 Int64 Int64 DataType
─────┼──────────────────────────────────────────────────────────────────
1 │ Rating 1900.03 1501 1854.0 2657 0 Int64
2 │ Popularity 91.9069 -17 92.0 100 0 Int64
对于评级列中的每个唯一值,我们希望计算流行度列的平均值。我们将分两步进行这项任务:
-
创建一个字典,将给定的评级值映射到数据框中可以找到的行向量。
-
使用这个字典来计算每个唯一评级值的平均流行度。
我们从第一个任务开始,创建一个字典并将评级映射到可以找到它的数据框行:
julia> rating_mapping = Dict{Int, Vector{Int}}() ❶
Dict{Int64, Vector{Int64}}()
julia> for (i, rating) in enumerate(good.Rating) ❷
if haskey(rating_mapping, rating) ❸
push!(rating_mapping[rating], i) ❹
else
rating_mapping[rating] = [i] ❺
end
end
julia> rating_mapping
Dict{Int64, Vector{Int64}} with 1157 entries:
2108 => [225, 6037, 6254, 7024, 8113, 8679, 8887, 131...
2261 => [361, 2462, 5276, 6006, 6409, 6420, 9089, 101...
1953 => [655, 984, 1290, 1699, 2525, 2553, 3195, 3883...
2288 => [864, 1023, 2019, 3475, 4164, 9424, 9972, 123...
1703 => [68, 464, 472, 826, 1097, 1393, 2042, 2110, 4...
⋮ => ⋮
❶ 创建一个空字典,用于存储映射
❷ 遍历 good.Rating 向量的所有元素,并跟踪迭代元素的索引和值
❸ 检查我们是否已经遇到了给定的评级值
❹ 如果我们看到了给定的评级值,则将其索引追加到字典中的现有条目
❺ 如果我们还没有看到给定的评级值,则在字典中创建一个新的条目
让我们回顾一下这段代码的关键部分。在 for 循环中使用的 enumerate(good.Rating)表达式产生(i, rating)元组,其中 i 是一个从 1 开始的计数器,rating 是从 good.Rating 向量中取出的第 i 个值。使用 enumerate 在您需要不仅迭代 rating 的值,还需要迭代次数时很有用。
接下来,我们检查我们得到的评级是否之前已经出现过。如果我们已经在 rating_mapping 字典中有了它,我们就检索映射到这个评级值的索引向量,并使用 push!函数将行号 i 添加到这个向量的末尾。另一方面,如果我们还没有看到给定的评级,我们就在字典中创建一个新的条目,将评级映射到一个只包含单个整数 i 的向量。
让我们尝试获取评级等于 2108 的 rating_mapping 字典中存储的行的数据框:
julia> good[rating_mapping[2108], :]
457×2 DataFrame
Row │ Rating Popularity
│ Int64 Int64
─────┼────────────────────
1 │ 2108 95
2 │ 2108 90
3 │ 2108 90
: │ : :
456 │ 2108 91
457 │ 2108 92
452 rows omitted
看起来我们只得到了具有 2108 评级的行。我们可以通过使用 unique 函数来确保这一点:
julia> unique(good[rating_mapping[2108], :].Rating)
1-element Vector{Int64}:
2108
事实上,在我们的选择中,只有 2108 这个值在评级列中。
练习 9.2 确保存储在 rating_mapping 字典中的值加起来代表我们好数据框的所有行索引。为此,检查这些向量的长度之和是否等于好数据框中的行数。
对于 2108 评级计算我们的流行度列的平均评级现在变得容易:
julia> using Statistics
julia> mean(good[rating_mapping[2108], "Popularity"])
91.64989059080963
现在我们已经准备好了所有必要的组件来创建一个图表,展示评分与谜题流行度之间的关系。首先,使用 unique 函数再次创建一个唯一评分值的向量:
julia> ratings = unique(good.Rating)
1157-element Vector{Int64}:
1765
1525
1560
⋮
2616
2619
2631
接下来,我们计算每个唯一评分值的平均流行度:
julia> mean_popularities = map(ratings) do rating
indices = rating_mapping[rating]
popularities = good[indices, "Popularity"]
return mean(popularities)
end
1157-element Vector{Float64}:
92.6219512195122
91.7780580075662
91.79565772669221
⋮
88.87323943661971
89.56140350877193
89.34782608695652
如果你想要刷新你对 map 函数如何在 do-end 块中工作的理解,请参阅第二章。为了得到期望的结果,我们本可以使用列表推导式而不是 map 函数。表达式如下:
[mean(good[rating_mapping[rating], "Popularity"]) for rating in ratings]
然而,我更喜欢使用 map 函数的解决方案,因为在我看来,代码更容易理解。
最后,我们可以执行所需的图表:
julia> using Plots
julia> scatter(ratings, mean_popularities;
xlabel="rating", ylabel="mean popularity", legend=false)
图 9.3 显示了结果。

图 9.3 展示了评分与流行度之间的关系,表明结果存在一些噪声。让我们创建一个图表,展示评分与平均流行度之间的平滑关系。为此,我们将使用一个流行的局部回归模型,称为 LOESS。
9.2.2 拟合 LOESS 回归
在本节中,你将学习如何将 LOESS 回归拟合到你的数据中,并使用拟合的模型进行预测。
图 9.3 中呈现的关系表明结果存在一些噪声。让我们创建一个图表,展示评分与平均流行度之间的平滑关系。为此,我们将使用一个流行的局部回归模型,称为 LOESS。
LOESS 回归
局部估计散点图平滑(LOESS)模型最初是为散点图平滑开发的。你可以在 William S. Cleveland 和 E. Grosse 的《局部回归的计算方法》(doi.org/10.1007/BF01890836)中找到更多关于此方法的信息。
在 Julia 中,Loess.jl 包允许你构建 LOESS 回归模型。
我们将创建一个 LOESS 模型,使用它进行预测,并在我们的图表中添加一条线。首先准备预测:
julia> using Loess
julia> model = loess(ratings, mean_popularities);
julia> ratings_predict = float(sort(ratings))
1157-element Vector{Float64}:
1501.0
1502.0
1503.0
⋮
2655.0
2656.0
2657.0
julia> popularity_predict = predict(model, ratings_predict)
1157-element Vector{Float64}:
91.78127959282982
91.78699303591367
91.7926814281816
⋮
89.58061736598427
89.58011426583589
89.57962657070658
注意,为了进行预测,我们首先使用 sort 函数对评分进行排序。这样做是为了使最终的图表看起来更美观,因为我们希望点在 x 轴上按顺序排列。(作为对排序数据的替代,你可以在 plot 函数中传递 serisetype=:line 关键字参数。)此外,我们对生成的向量使用 float 函数。原因是 predict 函数只接受浮点数向量,而我们的原始评分向量包含整数,不是浮点数。
你可以通过运行 methods 函数并将 predict 作为其参数来检查 predict 函数接受哪些参数:
julia> methods(predict)
# 3 methods for generic function "predict":
[1] predict(model::Loess.LoessModel{T}, z::T)
where T<:AbstractFloat in Loess at ...
[2] predict(model::Loess.LoessModel{T}, zs::AbstractVector{T})
where T<:AbstractFloat in Loess at ...
[3] predict(model::Loess.LoessModel{T}, zs::AbstractMatrix{T})
where T<:AbstractFloat in Loess at ...
如你所见,predict 函数有三个方法。每个方法都接受一个训练好的模型作为第一个参数,第二个参数可以是标量、向量或矩阵。在所有三种方法中,限制是第二个参数的元素必须是 AbstractFloat 的子类型。
现在,我们已经准备好在我们的图表中添加一条平滑线:
julia> plot!(ratings_predict, popularity_predict; width=5, color="black")
注意,我们使用 plot!来向已经存在的图表中添加额外的线。结果如图 9.4 所示。

图 9.4 将局部回归图添加到谜题评分与其流行度之间的关系中,证实了具有最高平均流行度的谜题评分为约 1750。
从图 9.4 中,我们可以看到最受欢迎的谜题的评分为约 1750,因此如果谜题要么太简单要么太难,其流行度就会较低。
从数据科学的角度来看,当然,这种分析有点简化:
-
我省略了评分都带有不确定性(RatingDeviation 列测量它)的事实。
-
流行度也是基于用户响应样本的一部分。
-
对于不同的评分,我们有不同数量的谜题。
-
我尚未优化 LOESS 模型中的平滑处理(这可以通过在 Loess.loess 函数中使用 span 关键字参数来完成;请参阅
github.com/JuliaStats/Loess.jl)。
更仔细的分析可能会考虑所有这些因素,但我决定省略这种分析,以保持示例简单,并专注于本章中涵盖的索引主题。
练习 9.3 检查在 loess 函数中更改 span 关键字参数值的影响。默认情况下,此参数的值为 0.75。将其设置为 0.25 并向图 9.4 所示的图中添加另一条预测线。使线条为黄色,宽度为 5。
作为本章的最后一个例子,正如之前所承诺的,让我们看看我们如何可以使用 DataFrames.jl 的更高级功能来聚合我们的分析数据,以通过评分获取流行度平均值:
julia> combine(groupby(good, :Rating), :Popularity => mean)
1157×2 DataFrame
Row │ Rating Popularity_mean
│ Int64 Float64
──────┼─────────────────────────
1 │ 1501 91.3822
2 │ 1502 91.8164
3 │ 1503 91.6671
: │ : :
1156 │ 2656 89.6162
1157 │ 2657 89.398
1152 rows omitted
有关此代码的意义及其执行的精确规则,请参阅第 11、12 和 13 章。我决定在这里展示此代码,以便让你清楚地知道,在第 9.2.1 节中使用字典进行的计算并不是在 DataFrames.jl 中进行数据聚合的惯用方式。尽管如此,我还是想在那一节中展示低级方法,因为我相信它很好地解释了如何使用循环、字典和数据框索引编写更复杂的数据处理代码,正如你偶尔需要编写这样的低级代码一样。
摘要
-
在数据框中进行索引始终需要传递行选择器和列选择器,其一般形式为 data_frame[row_selector, column_selector]。这种方法确保阅读你代码的人会立即看到预期的输出。
-
DataFrames.jl 定义了一系列可接受的列选择器,可以是整数、字符串、符号、向量、正则表达式或 :, Not, Between, Cols, 或 All 表达式。这种灵活性是必需的,因为用户经常希望使用复杂的模式进行列选择。请注意,1 和 [1] 选择器并不等价。尽管两者都指向第一列,但前者是从数据框中提取它,而后者则创建一个只包含这一列的数据框。
-
要选择数据框的行,您可以使用整数、向量、Not、: 和 ! 表达式。同样,列选择器 1 和 [1] 并不等效。第一个创建一个 DataFrameRow,而第二个创建一个数据框。
-
: 和 ! 都会从数据框中选择所有行。它们之间的区别在于:: 会复制数据框中存储的向量,而 ! 会重用源数据框中存储的向量。使用 ! 更快且内存使用更少,但可能导致难以捕捉的 bug,因此我建议除非用户的代码对性能敏感,否则不要使用它。
-
您可以使用 @view 宏来创建 DataFrame 对象的视图,就像您为数组做的那样。重要的是要记住,视图与父对象共享内存。因此,它们创建速度快且内存使用量少,但在此同时,您在修改其内容时需要小心。特别是,如果您选择数据框的单行和多列,您将得到一个 DataFrameRow 对象,它是数据框单行的视图。
-
== 中缀运算符比较容器(如向量或数据框)的内容。=== 运算符可以用来检查比较的对象是否相同(在可变容器最常见的案例中,这检查它们是否存储在相同的内存位置)。
-
您可以使用字典来帮助您进行数据的聚合。这种方法允许您独立于存储数据的容器类型处理数据。然而,如果您使用 DataFrames.jl,您还可以使用 groupby 函数来达到相同的结果。此功能的详细信息在第 11、12 和 13 章中解释。
-
Loess.jl 包可以用来构建局部回归模型。这些模型在特征和目标变量之间存在非线性关系时使用。
10 创建数据框对象
本章涵盖
-
创建数据框
-
使用 RCall.jl 与 R 语言集成
-
理解 Tables.jl 接口
-
绘制相关矩阵图
-
通过向其中添加行来迭代构建数据框
-
序列化 Julia 对象
在第八章中,我向您介绍了如何使用从 CSV 文件加载的样本数据来处理数据框。在本章中,我将向您展示更多将不同类型的数据值转换为 DataFrame 对象以及从 DataFrame 对象转换回来的方法。您需要具备这些基本知识,以便能够高效地使用 DataFrames.jl 包。您必须准备好源数据可能以各种格式出现,并且您需要知道如何将这些数据转换为 DataFrame。
由于创建 DataFrame 对象的话题范围很广,在本章中,我通过几个小型任务作为应用您所学概念的示例。跟随一个复杂的例子(就像我们在第八章和第九章中处理 Lichess 谜题数据那样)将无法展示实践中所有有用的选项。为了确保本章除了教您如何创建数据框之外,还能为您提供有用的数据分析食谱,我们将创建一个存储在数据框中的数据的相关矩阵图。
我将本章分为两个部分,以帮助您轻松导航可用的选项,并专注于您日常工作中最相关的场景:
-
在第 10.1 节中,您将学习从不同类型的数据对象创建数据框的各种方法。如果您已经拥有想要存储在数据框中的数据,您将需要执行此类操作。
-
在第 10.2 节中,您将学习通过向其中添加新行来迭代地创建数据框。如果您想要存储在数据框中的数据是在程序运行时生成的(例如,如果您收集模拟实验的结果),您将使用这种方法来创建数据框对象。
在第 10.1.2 节中,我们将使用 RCall.jl 包,该包提供了 Julia 与 R 之间的集成。在该节中运行示例需要您计算机上有一个正确配置的 R 安装。因此,请确保遵循附录 A 中的环境设置说明。
10.1 回顾创建数据框的最重要方法
在本节中,您将学习从与 DataFrame 类型不同的源数据创建数据框的三个最常见方法。这是您需要掌握的基本技能,因为您的源数据可能有各种格式。然而,为了使用 DataFrames.jl 包提供的功能,您首先需要创建一个具有 DataFrame 类型的对象。
在本节中,我通过使用我们在第四章中处理过的 Anscombe 的四重奏数据来展示最常见的场景。在这些场景中,我们通过以下方式创建数据框:
-
矩阵
-
向量集合
-
The Tables.jl interface
此外,你还将学习如何使用 RCall.jl 将 Julia 与 R 语言集成以及如何创建存储在数据框中的数据的相关矩阵的绘图。在我们开始创建数据框对象之前,我们首先需要重新创建第四章中使用的 Anscombe 的四重奏数据:
julia> aq = [10.0 8.04 10.0 9.14 10.0 7.46 8.0 6.58
8.0 6.95 8.0 8.14 8.0 6.77 8.0 5.76
13.0 7.58 13.0 8.74 13.0 12.74 8.0 7.71
9.0 8.81 9.0 8.77 9.0 7.11 8.0 8.84
11.0 8.33 11.0 9.26 11.0 7.81 8.0 8.47
14.0 9.96 14.0 8.1 14.0 8.84 8.0 7.04
6.0 7.24 6.0 6.13 6.0 6.08 8.0 5.25
4.0 4.26 4.0 3.1 4.0 5.39 19.0 12.50
12.0 10.84 12.0 9.13 12.0 8.15 8.0 5.56
7.0 4.82 7.0 7.26 7.0 6.42 8.0 7.91
5.0 5.68 5.0 4.74 5.0 5.73 8.0 6.89];
首先,确保加载 DataFrames.jl 包:
julia> using DataFrames
10.1.1 从矩阵创建数据框
在本节中,你将学习如何从矩阵创建数据框,因为矩阵是存储你可能想要分析的数据的常见格式。
Julia 中 Matrix 和 DataFrame 之间的一个区别是 Matrix 不支持列名。因此,当我们向 DataFrame 构造函数传递 Matrix 时,我们需要提供列名。这些名称可以是字符串向量或符号向量。我在下面的列表中展示了这两种选项。
列表 10.1 创建 aq1 数据框
julia> aq1 = DataFrame(aq, ["x1", "y1", "x2", "y2", "x3", "y3", "x4", "y4"])❶
11×8 DataFrame
Row │ x1 y1 x2 y2 x3 y3 x4 y4
│ Float64 Float64 Float64 Float64 Float64 Float64 Float64 Float64
────┼─────────────────────────────────────────────────────────────────
1 │ 10.0 8.04 10.0 9.14 10.0 7.46 8.0 6.58
2 │ 8.0 6.95 8.0 8.14 8.0 6.77 8.0 5.76
3 │ 13.0 7.58 13.0 8.74 13.0 12.74 8.0 7.71
4 │ 9.0 8.81 9.0 8.77 9.0 7.11 8.0 8.84
5 │ 11.0 8.33 11.0 9.26 11.0 7.81 8.0 8.47
6 │ 14.0 9.96 14.0 8.1 14.0 8.84 8.0 7.04
7 │ 6.0 7.24 6.0 6.13 6.0 6.08 8.0 5.25
8 │ 4.0 4.26 4.0 3.1 4.0 5.39 19.0 12.5
9 │ 12.0 10.84 12.0 9.13 12.0 8.15 8.0 5.56
10 │ 7.0 4.82 7.0 7.26 7.0 6.42 8.0 7.91
11 │ 5.0 5.68 5.0 4.74 5.0 5.73 8.0 6.89
julia> DataFrame(aq, [:x1, :y1, :x2, :y2, :x3, :y3, :x4, :y4]) ❷
11×8 DataFrame
Row │ x1 y1 x2 y2 x3 y3 x4 y4
│ Float64 Float64 Float64 Float64 Float64 Float64 Float64 Float64
────┼─────────────────────────────────────────────────────────────────
1 │ 10.0 8.04 10.0 9.14 10.0 7.46 8.0 6.58
2 │ 8.0 6.95 8.0 8.14 8.0 6.77 8.0 5.76
3 │ 13.0 7.58 13.0 8.74 13.0 12.74 8.0 7.71
4 │ 9.0 8.81 9.0 8.77 9.0 7.11 8.0 8.84
5 │ 11.0 8.33 11.0 9.26 11.0 7.81 8.0 8.47
6 │ 14.0 9.96 14.0 8.1 14.0 8.84 8.0 7.04
7 │ 6.0 7.24 6.0 6.13 6.0 6.08 8.0 5.25
8 │ 4.0 4.26 4.0 3.1 4.0 5.39 19.0 12.5
9 │ 12.0 10.84 12.0 9.13 12.0 8.15 8.0 5.56
10 │ 7.0 4.82 7.0 7.26 7.0 6.42 8.0 7.91
11 │ 5.0 5.68 5.0 4.74 5.0 5.73 8.0 6.89
❶ 使用字符串向量作为列名
❷ 使用符号向量作为列名
当我们将 Matrix 转换为 DataFrame 时,Matrix 的列成为 DataFrame 的列。为了方便,你可以通过传递:auto 参数而不是列名向量来请求 DataFrame 构造函数自动创建列名:
julia> DataFrame(aq, :auto)
11×8 DataFrame
Row │ x1 x2 x3 x4 x5 x6 x7 x8
│ Float64 Float64 Float64 Float64 Float64 Float64 Float64 Float64
────┼─────────────────────────────────────────────────────────────────
1 │ 10.0 8.04 10.0 9.14 10.0 7.46 8.0 6.58
2 │ 8.0 6.95 8.0 8.14 8.0 6.77 8.0 5.76
3 │ 13.0 7.58 13.0 8.74 13.0 12.74 8.0 7.71
4 │ 9.0 8.81 9.0 8.77 9.0 7.11 8.0 8.84
5 │ 11.0 8.33 11.0 9.26 11.0 7.81 8.0 8.47
6 │ 14.0 9.96 14.0 8.1 14.0 8.84 8.0 7.04
7 │ 6.0 7.24 6.0 6.13 6.0 6.08 8.0 5.25
8 │ 4.0 4.26 4.0 3.1 4.0 5.39 19.0 12.5
9 │ 12.0 10.84 12.0 9.13 12.0 8.15 8.0 5.56
10 │ 7.0 4.82 7.0 7.26 7.0 6.42 8.0 7.91
11 │ 5.0 5.68 5.0 4.74 5.0 5.73 8.0 6.89
当我们使用:auto 选项时,生成的列名由一个 x 字符后跟列号组成。
类似于 Matrix 参数,DataFrame 构造函数接受一个向量作为第一个参数,列名作为第二个参数。让我们首先从我们的 aq 矩阵创建一个向量向量(回想一下我们在第四章讨论了 collect 和 eachcol 函数):
julia> aq_vec = collect(eachcol(aq))
8-element Vector{SubArray{Float64, 1, Matrix{Float64},
Tuple{Base.Slice{Base.OneTo{Int64}}, Int64}, true}}:
[10.0, 8.0, 13.0, 9.0, 11.0, 14.0, 6.0, 4.0, 12.0, 7.0, 5.0]
[8.04, 6.95, 7.58, 8.81, 8.33, 9.96, 7.24, 4.26, 10.84, 4.82, 5.68]
[10.0, 8.0, 13.0, 9.0, 11.0, 14.0, 6.0, 4.0, 12.0, 7.0, 5.0]
[9.14, 8.14, 8.74, 8.77, 9.26, 8.1, 6.13, 3.1, 9.13, 7.26, 4.74]
[10.0, 8.0, 13.0, 9.0, 11.0, 14.0, 6.0, 4.0, 12.0, 7.0, 5.0]
[7.46, 6.77, 12.74, 7.11, 7.81, 8.84, 6.08, 5.39, 8.15, 6.42, 5.73]
[8.0, 8.0, 8.0, 8.0, 8.0, 8.0, 8.0, 19.0, 8.0, 8.0, 8.0]
[6.58, 5.76, 7.71, 8.84, 8.47, 7.04, 5.25, 12.5, 5.56, 7.91, 6.89]
从 aq_vec 对象创建 DataFrame 的方法是传递列名作为第二个参数
julia> DataFrame(aq_vec, ["x1", "y1", "x2", "y2", "x3", "y3", "x4", "y4"])
11×8 DataFrame
Row │ x1 y1 x2 y2 x3 y3 x4 y4
│ Float64 Float64 Float64 Float64 Float64 Float64 Float64 Float64
────┼─────────────────────────────────────────────────────────────────
1 │ 10.0 8.04 10.0 9.14 10.0 7.46 8.0 6.58
2 │ 8.0 6.95 8.0 8.14 8.0 6.77 8.0 5.76
3 │ 13.0 7.58 13.0 8.74 13.0 12.74 8.0 7.71
4 │ 9.0 8.81 9.0 8.77 9.0 7.11 8.0 8.84
5 │ 11.0 8.33 11.0 9.26 11.0 7.81 8.0 8.47
6 │ 14.0 9.96 14.0 8.1 14.0 8.84 8.0 7.04
7 │ 6.0 7.24 6.0 6.13 6.0 6.08 8.0 5.25
8 │ 4.0 4.26 4.0 3.1 4.0 5.39 19.0 12.5
9 │ 12.0 10.84 12.0 9.13 12.0 8.15 8.0 5.56
10 │ 7.0 4.82 7.0 7.26 7.0 6.42 8.0 7.91
11 │ 5.0 5.68 5.0 4.74 5.0 5.73 8.0 6.89
或者传递:auto 关键字参数:
julia> DataFrame(aq_vec, :auto)
11×8 DataFrame
Row │ x1 x2 x3 x4 x5 x6 x7 x8
│ Float64 Float64 Float64 Float64 Float64 Float64 Float64 Float64
────┼─────────────────────────────────────────────────────────────────
1 │ 10.0 8.04 10.0 9.14 10.0 7.46 8.0 6.58
2 │ 8.0 6.95 8.0 8.14 8.0 6.77 8.0 5.76
3 │ 13.0 7.58 13.0 8.74 13.0 12.74 8.0 7.71
4 │ 9.0 8.81 9.0 8.77 9.0 7.11 8.0 8.84
5 │ 11.0 8.33 11.0 9.26 11.0 7.81 8.0 8.47
6 │ 14.0 9.96 14.0 8.1 14.0 8.84 8.0 7.04
7 │ 6.0 7.24 6.0 6.13 6.0 6.08 8.0 5.25
8 │ 4.0 4.26 4.0 3.1 4.0 5.39 19.0 12.5
9 │ 12.0 10.84 12.0 9.13 12.0 8.15 8.0 5.56
10 │ 7.0 4.82 7.0 7.26 7.0 6.42 8.0 7.91
11 │ 5.0 5.68 5.0 4.74 5.0 5.73 8.0 6.89
10.1.2 从向量创建数据框
你经常会想要将存储列作为向量的对象转换为 DataFrame。与第 10.1.1 节中讨论的矩阵一样,向量是源数据可能最初存储的常见格式。例如,在列表 4.2 中,我们使用了以下命名元组来存储 Anscombe 的四重奏数据:
julia> data = (set1=(x=aq[:, 1], y=aq[:, 2]),
set2=(x=aq[:, 3], y=aq[:, 4]),
set3=(x=aq[:, 5], y=aq[:, 6]),
set4=(x=aq[:, 7], y=aq[:, 8]));
在命名元组数据中,我们将数据框的列存储为向量。例如,回想一下第四章中你可以如下检索 set1 数据集中的 x 列:
julia> data.set1.x
11-element Vector{Float64}:
10.0
8.0
13.0
9.0
11.0
14.0
6.0
4.0
12.0
7.0
5.0
使用关键字参数的构造函数
我们可以通过两种方式将向量传递给 DataFrame 构造函数。第一种是使用关键字参数:
julia> DataFrame(x1=data.set1.x, y1=data.set1.y,
x2=data.set2.x, y2=data.set2.y,
x3=data.set3.x, y3=data.set3.y,
x4=data.set4.x, y4=data.set4.y)
11×8 DataFrame
Row │ x1 y1 x2 y2 x3 y3 x4 y4
│ Float64 Float64 Float64 Float64 Float64 Float64 Float64 Float64
────┼─────────────────────────────────────────────────────────────────
1 │ 10.0 8.04 10.0 9.14 10.0 7.46 8.0 6.58
2 │ 8.0 6.95 8.0 8.14 8.0 6.77 8.0 5.76
3 │ 13.0 7.58 13.0 8.74 13.0 12.74 8.0 7.71
4 │ 9.0 8.81 9.0 8.77 9.0 7.11 8.0 8.84
5 │ 11.0 8.33 11.0 9.26 11.0 7.81 8.0 8.47
6 │ 14.0 9.96 14.0 8.1 14.0 8.84 8.0 7.04
7 │ 6.0 7.24 6.0 6.13 6.0 6.08 8.0 5.25
8 │ 4.0 4.26 4.0 3.1 4.0 5.39 19.0 12.5
9 │ 12.0 10.84 12.0 9.13 12.0 8.15 8.0 5.56
10 │ 7.0 4.82 7.0 7.26 7.0 6.42 8.0 7.91
11 │ 5.0 5.68 5.0 4.74 5.0 5.73 8.0 6.89
使用这种风格,我们传递一个列名后跟一个我们想要存储在这个列中的向量。请注意,我们利用了 Julia 中的关键字参数不需要任何额外的装饰器(例如,在 Symbol 的情况下使用:前缀)的事实;请参阅第 2.4 节中关于关键字参数的讨论。
在这个例子中,我们将包含四个数据集的数据对象展开到八个列中。在第 10.1.3 节中,您将看到另一种将数据对象转换为数据框的方法,它依赖于 Tables.jl 接口。
使用对构造函数
创建相同数据框的另一种方法是使用位置参数和 Pair 表示法列名 => 列数据:
julia> DataFrame(:x1 => data.set1.x, :y1 => data.set1.y,
:x2 => data.set2.x, :y2 => data.set2.y,
:x3 => data.set3.x, :y3 => data.set3.y,
:x4 => data.set4.x, :y4 => data.set4.y)
11×8 DataFrame
Row │ x1 y1 x2 y2 x3 y3 x4 y4
│ Float64 Float64 Float64 Float64 Float64 Float64 Float64 Float64
────┼─────────────────────────────────────────────────────────────────
1 │ 10.0 8.04 10.0 9.14 10.0 7.46 8.0 6.58
2 │ 8.0 6.95 8.0 8.14 8.0 6.77 8.0 5.76
3 │ 13.0 7.58 13.0 8.74 13.0 12.74 8.0 7.71
4 │ 9.0 8.81 9.0 8.77 9.0 7.11 8.0 8.84
5 │ 11.0 8.33 11.0 9.26 11.0 7.81 8.0 8.47
6 │ 14.0 9.96 14.0 8.1 14.0 8.84 8.0 7.04
7 │ 6.0 7.24 6.0 6.13 6.0 6.08 8.0 5.25
8 │ 4.0 4.26 4.0 3.1 4.0 5.39 19.0 12.5
9 │ 12.0 10.84 12.0 9.13 12.0 8.15 8.0 5.56
10 │ 7.0 4.82 7.0 7.26 7.0 6.42 8.0 7.91
11 │ 5.0 5.68 5.0 4.74 5.0 5.73 8.0 6.89
再次,我们本可以使用字符串而不是符号,这在您希望列包含非标准字符(如空格)时非常有用。
使用 Pair 表示法的附加功能是,我们不必传递多个位置参数,而可以传递这些对的向量以获得相同的结果(我省略了打印以节省空间,因为输出与前面的示例相同):
julia> DataFrame([:x1 => data.set1.x, :y1 => data.set1.y,
:x2 => data.set2.x, :y2 => data.set2.y,
:x3 => data.set3.x, :y3 => data.set3.y,
:x4 => data.set4.x, :y4 => data.set4.y]);
这种方法的好处是什么?它很有用,因为这样就可以轻松地使用推导式遍历数据 NamedTuple。让我们一步一步来做。首先,创建一个迭代数据集编号(从 1 到 4)和列(:x 和 :y)的向量:
julia> [(i, v) for i in 1:4 for v in [:x, :y]]
8-element Vector{Tuple{Int64, Symbol}}:
(1, :x)
(1, :y)
(2, :x)
(2, :y)
(3, :x)
(3, :y)
(4, :x)
(4, :y)
注意,在这个推导式中,我们使用了一个双重循环,它产生了一个元组向量。接下来,我们可以使用将传递的参数连接成字符串的字符串函数将这些值转换为列名:
julia> [string(v, i) for i in 1:4 for v in [:x, :y]]
8-element Vector{String}:
"x1"
"y1"
"x2"
"y2"
"x3"
"y3"
"x4"
"y4"
我们几乎完成了。接下来,使用推导式,创建一个向量,将每个列名映射到数据对象中的列值:
julia> [string(v, i) => getproperty(data[i], v)
for i in 1:4 for v in [:x, :y]]
8-element Vector{Pair{String, Vector{Float64}}}:
"x1" => [10.0, 8.0, 13.0, 9.0, 11.0, 14.0, 6.0, 4.0, 12.0, 7.0, 5.0]
"y1" => [8.04, 6.95, 7.58, 8.81, 8.33, 9.96, 7.24, 4.26, 10.84, 4.82, 5.68]
"x2" => [10.0, 8.0, 13.0, 9.0, 11.0, 14.0, 6.0, 4.0, 12.0, 7.0, 5.0]
"y2" => [9.14, 8.14, 8.74, 8.77, 9.26, 8.1, 6.13, 3.1, 9.13, 7.26, 4.74]
"x3" => [10.0, 8.0, 13.0, 9.0, 11.0, 14.0, 6.0, 4.0, 12.0, 7.0, 5.0]
"y3" => [7.46, 6.77, 12.74, 7.11, 7.81, 8.84, 6.08, 5.39, 8.15, 6.42, 5.73]
"x4" => [8.0, 8.0, 8.0, 8.0, 8.0, 8.0, 8.0, 19.0, 8.0, 8.0, 8.0]
"y4" => [6.58, 5.76, 7.71, 8.84, 8.47, 7.04, 5.25, 12.5, 5.56, 7.91, 6.89]
在此代码中,您可以看到对 getproperty 函数的调用。您可以使用此函数通过变量获取 NamedTuple 的属性。因此,编写 data.set1 等同于编写 getproperty(data, :set1)。
现在我们有一个包含列名和列值的对列向量,我们可以将其传递给 DataFrame 构造函数(我再次省略了输出,因为它与前面的情况相同):
julia> DataFrame([string(v, i) => getproperty(data[i], v)
for i in 1:4 for v in [:x, :y]]);
使用字典的构造函数
相关地,当您在 Julia 中使用 collect 函数收集字典时,您会得到一个将它们的键映射到值的对向量。例如:
julia> data_dict = Dict([string(v, i) => getproperty(data[i], v)
for i in 1:4 for v in [:x, :y]])
Dict{String, Vector{Float64}} with 8 entries:
"y3" => [7.46, 6.77, 12.74, 7.11, 7.81, 8.84, 6.08, 5.39, 8.15, 6.42, 5.73]
"x1" => [10.0, 8.0, 13.0, 9.0, 11.0, 14.0, 6.0, 4.0, 12.0, 7.0, 5.0]
"y1" => [8.04, 6.95, 7.58, 8.81, 8.33, 9.96, 7.24, 4.26, 10.84, 4.82, 5.68]
"y4" => [6.58, 5.76, 7.71, 8.84, 8.47, 7.04, 5.25, 12.5, 5.56, 7.91, 6.89]
"x4" => [8.0, 8.0, 8.0, 8.0, 8.0, 8.0, 8.0, 19.0, 8.0, 8.0, 8.0]
"x2" => [10.0, 8.0, 13.0, 9.0, 11.0, 14.0, 6.0, 4.0, 12.0, 7.0, 5.0]
"y2" => [9.14, 8.14, 8.74, 8.77, 9.26, 8.1, 6.13, 3.1, 9.13, 7.26, 4.74]
"x3" => [10.0, 8.0, 13.0, 9.0, 11.0, 14.0, 6.0, 4.0, 12.0, 7.0, 5.0]
julia> collect(data_dict)
8-element Vector{Pair{String, Vector{Float64}}}:
"y3" => [7.46, 6.77, 12.74, 7.11, 7.81, 8.84, 6.08, 5.39, 8.15, 6.42, 5.73]
"x1" => [10.0, 8.0, 13.0, 9.0, 11.0, 14.0, 6.0, 4.0, 12.0, 7.0, 5.0]
"y1" => [8.04, 6.95, 7.58, 8.81, 8.33, 9.96, 7.24, 4.26, 10.84, 4.82, 5.68]
"y4" => [6.58, 5.76, 7.71, 8.84, 8.47, 7.04, 5.25, 12.5, 5.56, 7.91, 6.89]
"x4" => [8.0, 8.0, 8.0, 8.0, 8.0, 8.0, 8.0, 19.0, 8.0, 8.0, 8.0]
"x2" => [10.0, 8.0, 13.0, 9.0, 11.0, 14.0, 6.0, 4.0, 12.0, 7.0, 5.0]
"y2" => [9.14, 8.14, 8.74, 8.77, 9.26, 8.1, 6.13, 3.1, 9.13, 7.26, 4.74]
"x3" => [10.0, 8.0, 13.0, 9.0, 11.0, 14.0, 6.0, 4.0, 12.0, 7.0, 5.0]
因此,您可以编写 DataFrame(collect(data_dict)) 来从 data_dict 字典创建数据框。然而,在这种情况下,这是不必要的。DataFrame 构造函数自动处理此操作,您只需将其传递给字典即可获得数据框:
julia> DataFrame(data_dict)
11×8 DataFrame
Row │ x1 y1 x2 y2 x3 y3 x4 y4
│ Float64 Float64 Float64 Float64 Float64 Float64 Float64 Float64
────┼─────────────────────────────────────────────────────────────────
1 │ 10.0 8.04 10.0 9.14 10.0 7.46 8.0 6.58
2 │ 8.0 6.95 8.0 8.14 8.0 6.77 8.0 5.76
3 │ 13.0 7.58 13.0 8.74 13.0 12.74 8.0 7.71
4 │ 9.0 8.81 9.0 8.77 9.0 7.11 8.0 8.84
5 │ 11.0 8.33 11.0 9.26 11.0 7.81 8.0 8.47
6 │ 14.0 9.96 14.0 8.1 14.0 8.84 8.0 7.04
7 │ 6.0 7.24 6.0 6.13 6.0 6.08 8.0 5.25
8 │ 4.0 4.26 4.0 3.1 4.0 5.39 19.0 12.5
9 │ 12.0 10.84 12.0 9.13 12.0 8.15 8.0 5.56
10 │ 7.0 4.82 7.0 7.26 7.0 6.42 8.0 7.91
11 │ 5.0 5.68 5.0 4.74 5.0 5.73 8.0 6.89
在 Dict 字典的常见情况下,如示例所示,结果数据框的列按其名称排序,因为 Dict 的迭代顺序未定义(有关此主题的讨论,请参阅第四章)。
在创建数据框时,一个重要的考虑因素是内存管理。您有两个选项:
-
让 DataFrame 构造函数复制传递给它的数据,这样数据框的列就会被重新分配。
-
让 DataFrame 构造函数重用传递给它的数据,这样数据框的列就不会被分配。
copycols 关键字参数
默认情况下,DataFrame 构造函数会复制数据。这是一个安全的方法,可以导致更少错误倾向的代码。然而,如果你担心内存使用或性能,你可以通过将 copycols=false 关键字参数传递给 DataFrame 构造函数来关闭复制。
让我们比较这些选项。首先,检查列默认是复制的:
julia> df1 = DataFrame(x1=data.set1.x)
11×1 DataFrame
Row │ x1
│ Float64
─────┼─────────
1 │ 10.0
2 │ 8.0
3 │ 13.0
4 │ 9.0
5 │ 11.0
6 │ 14.0
7 │ 6.0
8 │ 4.0
9 │ 12.0
10 │ 7.0
11 │ 5.0
julia> df1.x1 === data.set1.x
false
现在,让我们调查非复制行为:
julia> df2 = DataFrame(x1=data.set1.x; copycols=false)
11×1 DataFrame
Row │ x1
│ Float64
─────┼─────────
1 │ 10.0
2 │ 8.0
3 │ 13.0
4 │ 9.0
5 │ 11.0
6 │ 14.0
7 │ 6.0
8 │ 4.0
9 │ 12.0
10 │ 7.0
11 │ 5.0
julia> df2.x1 === data.set1.x
true
练习 10.1 比较创建包含单个随机向量的数据框(长度为一百万)时,有和没有复制源向量的性能。你可以通过使用 rand(10⁶) 命令生成这个向量。
处理非标准参数的规则
在结束从向量创建 DataFrame 的讨论之前,我将评论 DataFrame 构造函数的一个便利功能。正如你在第五章中了解到的,默认情况下,Julia 从不隐式向量化你的代码。相反,你必须使用显式广播。
在 DataFrames.jl 中,为了用户方便,对这一规则做了例外。如果你将标量(例如,一个数字或一个字符串)传递给 DataFrame 构造函数,这个标量会自动重复,直到与构造函数中传递的向量的长度相匹配。这种行为被称为 伪广播。以下是一个例子:
julia> df = DataFrame(x=1:3, y=1)
3×2 DataFrame
Row │ x y
│ Int64
─────┼──────────────
1 │ 1 1
2 │ 2 1
3 │ 3 1
标量 1 被重复三次,以匹配 1:3 范围的长度。
另一个便利功能是,DataFrame 构造函数总是将范围(如前一个例子中传递的 1:3)收集到一个 Vector 中。你可以通过编写以下代码来检查它:
julia> df.x
3-element Vector{Int64}:
1
2
3
这种规则背后的原因是,大多数情况下,如果你在 DataFrame 中存储一个列,你希望这个列是可变的——也就是说,允许向其中添加元素或更改存储在其中的值。另一方面,范围是只读对象。这就是 DataFrame 构造函数总是将它们转换为向量的原因。
最后,伪广播仅适用于标量。如果你将不同长度的向量传递给 DataFrame 构造函数,你会得到一个错误:
julia> DataFrame(x=[1], y=[1, 2, 3])
ERROR: DimensionMismatch("column :x has length 1 and column :y has length 3")
这种行为可能会让 R 用户感到惊讶,因为当这些长度的最小公倍数等于最长传递向量的长度时,R 允许将不同长度的向量传递给数据框构造函数。例如,如果你使用长度为 6、2 和 3 的向量,你会得到一个包含六行的数据框。
Julia 与 R 的集成
为了展示 R 的这一特性,我将使用 RCall.jl 包,并解释如何将 R 数据框对象转换为 DataFrames.jl 的 DataFrame 对象。了解如何使用 RCall.jl 包是有用的,因为你可能已经编写了 R 代码,并希望将其作为 Julia 程序的一部分运行:
julia> using RCall
julia> r_df = R"data.frame(a=1:6, b=1:2, c=1:3)" ❶
RObject{VecSxp}
a b c
1 1 1 1
2 2 2 2
3 3 1 3
4 4 2 1
5 5 1 2
6 6 2 3
julia> julia_df = rcopy(r_df) ❷
6×3 DataFrame
Row │ a b c
│ Int64 Int64 Int64
─────┼─────────────────────
1 │ 1 1 1
2 │ 2 2 2
3 │ 3 1 3
4 │ 4 2 1
5 │ 5 1 2
6 │ 6 2 3
❶ 通过在包含 R 代码的字符串前加上 R 字符来执行 R 命令
❷ 使用 rcopy 函数将 R 数据框转换为 DataFrame
在加载 RCall.jl 包之后,我们首先创建 r_df 对象,它是一个 R 数据框。您执行任何 R 命令的一种方法是将它写入以 R 字符为前缀的字符串中。接下来,使用 rcopy 函数,我将 R 数据框转换为 DataFrames.jl 中定义的 DataFrame 对象。
示例显示,当创建数据框以使长度与 1:6 向量匹配时,R 会回收 1:2 和 1:3 向量。正如我解释的那样,在 DataFrames.jl 中不允许这种行为,因为它可能导致生产代码中难以捕捉的 bug。
RCall.jl 包
本节提供了一个最小示例,展示了如何使用 Julia 和 R 以及 RCall.jl 包一起工作。如果您想了解更多关于可用功能的信息,请参阅包文档(juliainterop.github.io/RCall.jl/stable/)。在这里,让我们讨论使用 RCall.jl 包最重要的方面。
首先,您需要安装 R 才能使用它。在某些计算环境中,安装 RCall.jl 包可能无法自动检测您的 R 安装。在这种情况下,请参阅安装 RCall.jl 手册(mng.bz/jAy8)以获取说明如何解决问题的说明。
在我们的示例代码中,我们使用以 R 字符为前缀的字符串来执行 R 代码。此外,RCall.jl 包提供了 R REPL 模式,您可以直接在终端中执行 R 代码。当您处于 Julia REPL 并按下 $(美元)键时,提示符将从 julia> 切换到 R>,并且 R 模式将被激活。您可以通过按退格键退出此模式。R REPL 模式在交互式会话中非常有用。
您可以在包手册的“入门”部分找到有关如何使用 R REPL 模式的更详细解释,以及 RCall.jl 包的附加功能描述,这些功能我在这里没有描述。(mng.bz/WM7l)
10.1.3 使用 Tables.jl 接口创建数据框
在本小节中,我们讨论了 Tables.jl 包,它提供了简单而强大的接口函数,用于处理各种 表格数据——数据中观测值存储在行中,变量存储在列中。Tables.jl 包是必需的,因为在许多分析任务中,您会得到一个类似表格的对象,但它不是一个 DataFrame。
例如,如果您使用 DifferentialEquations.jl 包求解微分方程,您可能希望将解决方案存储为数据框,如包文档所示(mng.bz/82l5)。关键是 DifferentialEquations.jl 实现了一个适当的接口,允许进行此类转换;它不需要将 DataFrames.jl 作为其依赖项。如第一章所述,这种可组合性是 Julia 的一个优势。
DataFrame 是支持 Tables.jl 包提供的表格接口的类型的例子。如果你有一个支持 Tables.jl 接口的对象,你可以将其作为单个参数传递给 DataFrame 构造函数,并得到一个 DataFrame 作为结果。
支持与 Tables.jl 集成的包列表非常广泛(mng.bz/E0xX)。在本节中,我们将集中讨论两种最常见的支持此接口的对象类型,它们在 Base Julia 中定义:
-
向量 NamedTuple
-
NamedTuple 对象的迭代器
Julia 中的迭代器
你在第四章学习了 Julia 支持的各种集合类型。这些包括数组、元组、命名元组和字典。
许多 Julia 集合都可以迭代。你可以这样想。如果一个集合 c 是可迭代的,你可以写一个 for 循环,如下所示,以顺序检索集合 c 在这个循环中的所有元素(请注意,此代码不可运行):
for v in c
# loop body
end
此外,许多函数,如 map,依赖于集合的可迭代性。支持这种使用形式的类型被称为实现了迭代接口。如果你定义了自己的类型并希望支持此接口,你可以查看 Julia 手册(mng.bz/82w2)来了解如何实现。
你可以在 Julia 手册的“迭代”部分找到支持迭代接口的标准 Julia 集合列表(mng.bz/N5xv)。
在 NamedTuple 向量的第一种情况下,解释是直观的。NamedTuple 的字段名成为数据框的列名,向量成为其列。记住,这些向量必须具有相同的长度才能使操作生效。以下是一个使用 data.set1 NamedTuple 的示例:
julia> data.set1
(x = [10.0, 8.0, 13.0, 9.0, 11.0, 14.0, 6.0, 4.0, 12.0, 7.0, 5.0],
y = [8.04, 6.95, 7.58, 8.81, 8.33, 9.96, 7.24, 4.26, 10.84, 4.82, 5.68])
julia> DataFrame(data.set1)
11×2 DataFrame
Row │ x y
│ Float64 Float64
─────┼──────────────────
1 │ 10.0 8.04
2 │ 8.0 6.95
3 │ 13.0 7.58
4 │ 9.0 8.81
5 │ 11.0 8.33
6 │ 14.0 9.96
7 │ 6.0 7.24
8 │ 4.0 4.26
9 │ 12.0 10.84
10 │ 7.0 4.82
11 │ 5.0 5.68
第二种情况发生在传递 NamedTuple 对象的迭代器时。然后我们假设每个 NamedTuple 具有相同的字段集(使用第一个 NamedTuple 的字段名),并且每个 NamedTuple 用于创建一行数据。
让我们从最简单的例子开始,以确保我描述的规则清晰,然后我们将转向数据 NamedTuple 的更复杂的情况:
julia> DataFrame([(a=1, b=2), (a=3, b=4), (a=5, b=6)])
3×2 DataFrame
Row │ a b
│ Int64 Int64
─────┼──────────────
1 │ 1 2
2 │ 3 4
3 │ 5 6
在这种情况下,我们将一个向量(它是可迭代的)传递给 DataFrame 构造函数。这个向量包含三个元素,每个元素都是一个具有字段 a 和 b 的 NamedTuple。因此,结果我们得到一个包含三行两列的数据框:a 和 b。
让我们转向数据对象。它是一个 NamedTuple,其中包含 NamedTuple 对象:
julia> data
(set1 = (x = [10.0, 8.0, 13.0, 9.0, 11.0, 14.0, 6.0, 4.0, 12.0, 7.0, 5.0],
y = [8.04, 6.95, 7.58, 8.81, 8.33, 9.96, 7.24, 4.26, 10.84, 4.82, 5.68]),
set2 = (x = [10.0, 8.0, 13.0, 9.0, 11.0, 14.0, 6.0, 4.0, 12.0, 7.0, 5.0],
y = [9.14, 8.14, 8.74, 8.77, 9.26, 8.1, 6.13, 3.1, 9.13, 7.26, 4.74]),
set3 = (x = [10.0, 8.0, 13.0, 9.0, 11.0, 14.0, 6.0, 4.0, 12.0, 7.0, 5.0],
y = [7.46, 6.77, 12.74, 7.11, 7.81, 8.84, 6.08, 5.39, 8.15, 6.42, 5.73]),
set4 = (x = [8.0, 8.0, 8.0, 8.0, 8.0, 8.0, 8.0, 19.0, 8.0, 8.0, 8.0],
y = [6.58, 5.76, 7.71, 8.84, 8.47, 7.04, 5.25, 12.5, 5.56, 7.91, 6.89]))
如果数据是一个向量的命名元组,我们会得到一个包含四个列的数据框,分别是 set1、set2、set3 和 set4。然而,这个命名元组存储的是命名元组,因此这个规则不适用于它。由于命名元组是可迭代的并且存储命名元组,每个值都被视为创建的 DataFrame 中的一行。由于每个内部命名元组都包含字段 x 和 y,我们将得到一个包含 x 和 y 两列以及四个行的 DataFrame,代表我们处理的四个数据集。让我们看看在下一个列表中这是否成立。
列表 10.2 创建 aq2 数据框
julia> aq2 = DataFrame(data)
4×2 DataFrame
Row │ x y
│ Array... Array...
─────┼─────────────────────────────────────────────────────────────────────
1 │ 10.0, 8.0, 13.0, 9.0, 11.0, 14.... [8.04, 6.95, 7.58, 8.81, 8.33, 9...
2 │ [10.0, 8.0, 13.0, 9.0, 11.0, 14.... [9.14, 8.14, 8.74, 8.77, 9.26, 8...
3 │ [10.0, 8.0, 13.0, 9.0, 11.0, 14.... [7.46, 6.77, 12.74, 7.11, 7.81, ...
4 │ [8.0, 8.0, 8.0, 8.0, 8.0, 8.0, 8... [6.58, 5.76, 7.71, 8.84, 8.47, 7...
我们得到一个包含四个向量的向量,在 x 和 y 列中都有。注意,在列表 10.2 中,你可以看到 DataFrame 的列可以存储任何对象;在这种情况下,列存储向量。
10.1.4 绘制存储在数据框中的数据的相关矩阵
在本节中,我们将绘制存储在我们在 10.1 节创建的 aq1 数据框中的数据的相关矩阵。回想一下,这个数据框存储了安斯康姆的数据:
julia> aq1
11×8 DataFrame
Row │ x1 y1 x2 y2 x3 y3 x4 y4
│ Float64 Float64 Float64 Float64 Float64 Float64 Float64 Float64
────┼─────────────────────────────────────────────────────────────────
1 │ 10.0 8.04 10.0 9.14 10.0 7.46 8.0 6.58
2 │ 8.0 6.95 8.0 8.14 8.0 6.77 8.0 5.76
3 │ 13.0 7.58 13.0 8.74 13.0 12.74 8.0 7.71
4 │ 9.0 8.81 9.0 8.77 9.0 7.11 8.0 8.84
5 │ 11.0 8.33 11.0 9.26 11.0 7.81 8.0 8.47
6 │ 14.0 9.96 14.0 8.1 14.0 8.84 8.0 7.04
7 │ 6.0 7.24 6.0 6.13 6.0 6.08 8.0 5.25
8 │ 4.0 4.26 4.0 3.1 4.0 5.39 19.0 12.5
9 │ 12.0 10.84 12.0 9.13 12.0 8.15 8.0 5.56
10 │ 7.0 4.82 7.0 7.26 7.0 6.42 8.0 7.91
11 │ 5.0 5.68 5.0 4.74 5.0 5.73 8.0 6.89
首先,我们创建这个数据框列的相关矩阵,然后我们绘制它。为了计算相关矩阵,我们使用 StatsBase.jl 包中的 pairwise 函数。
这个函数接受两个参数。第一个参数是我们想要应用的函数——在我们的例子中,是从 Statistics 模块中计算皮尔逊相关性的 cor 函数。第二个参数是我们想要计算相关性的向量集合。由于我们的数据存储在数据框中,我们通过使用 eachcol 函数来获取这个集合:
julia> using Statistics
julia> using StatsBase
julia> cor_mat = pairwise(cor, eachcol(aq1))
8×8 Matrix{Float64}:
1.0 0.81642 1.0 0.81624 1.0 0.81629 -0.5 -0.31405
0.81642 1.0 0.81642 0.75001 0.81642 0.46872 -0.52909 -0.48912
1.0 0.81642 1.0 0.81624 1.0 0.81629 -0.5 -0.31405
0.81624 0.75001 0.81624 1.0 0.81624 0.58792 -0.71844 -0.47810
1.0 0.81642 1.0 0.81624 1.0 0.81629 -0.5 -0.31405
0.81629 0.46872 0.81629 0.58792 0.81629 1.0 -0.34466 -0.15547
-0.5 -0.52909 -0.5 -0.71844 -0.5 -0.34466 1.0 0.81652
-0.31405 -0.48912 -0.31405 -0.47810 -0.31405 -0.15547 0.81652 1.0
接下来,我们使用 Plots.jl 包中的 heatmap 函数绘制 cor_mat 矩阵。作为这个函数的第一个和第二个参数,我们传递变量的名称,这些名称是通过 names(aq1)调用获得的。第三个参数是 cor_mat 相关矩阵。
我们还传递了 aspect_ratio=:equal 和 size=(400,400)关键字参数,以确保我们的相关矩阵中的每个单元格都是一个正方形。如果不传递这些关键字参数,相关矩阵将不是正方形,而是水平方向比垂直方向更宽。此外,我们传递 rightmargin=5Plots.mm 以确保颜色条的注释不会被裁剪;我们已经在第七章中讨论了在绘制图 7.6 时添加额外填充的情况:
julia> using Plots
julia> heatmap(names(aq1), names(aq1), cor_mat;
aspect_ratio=:equal, size=(400, 400),
rightmargin=5Plots.mm)
图 10.1 显示了生成的图表。我们可以看到匹配变量的对——(:x1, :y1)、(:x2, :y2)、(:x3, :y3)和(:x4, :y4)——具有相似的皮尔逊相关系数。
![CH10_F01_Kaminski2
图 10.1 在绘制 aq1 数据框的相关矩阵时,浅色方块表示正相关,深色方块表示负相关。
10.2 逐步创建数据框
在许多情况下,你可能会想逐步创建一个数据框——例如,通过向现有数据框中添加新数据行。这种情况下最有用的一个场景是在你的程序中生成数据并希望将其存储在数据框中。我将在 10.2.3 小节中展示一个示例,我们将讨论二维随机游走的模拟。
本节涵盖了三种最常见的操作,允许你向数据框中添加行:
-
将多个数据框垂直连接到一个新的数据框中
-
在现有数据框中就地追加数据框
-
在现有数据框中就地添加新行
10.2.1 垂直连接数据框
在本节中,你将学习如何通过垂直连接将多个数据框合并为一个。当你有来自多个来源的数据但希望将其存储在一个数据框中时,这个操作通常很有必要。
我们首先创建几个我们将要垂直连接的数据框。在 10.1 节中,你学习了通过编写 DataFrame(data.set1),你可以从一个向量 NamedTuple 创建一个数据框。在本节中,我们首先为数据对象中包含的四个数据集创建四个数据框。接下来,我们将垂直连接这四个数据框。
在下一个列表中,我们将使用 map 函数从数据 NamedTuple 的四个字段中创建四个源数据框。
列表 10.3 使用 map 函数创建多个数据框
julia> data_dfs = map(DataFrame, data)
(set1 = 11×2 DataFrame
Row │ x y
│ Float64 Float64
─────┼──────────────────
1 │ 10.0 8.04
2 │ 8.0 6.95
3 │ 13.0 7.58
4 │ 9.0 8.81
5 │ 11.0 8.33
6 │ 14.0 9.96
7 │ 6.0 7.24
8 │ 4.0 4.26
9 │ 12.0 10.84
10 │ 7.0 4.82
11 │ 5.0 5.68, set2 = 11×2 DataFrame
Row │ x y
│ Float64 Float64
─────┼──────────────────
1 │ 10.0 9.14
2 │ 8.0 8.14
3 │ 13.0 8.74
4 │ 9.0 8.77
5 │ 11.0 9.26
6 │ 14.0 8.1
7 │ 6.0 6.13
8 │ 4.0 3.1
9 │ 12.0 9.13
10 │ 7.0 7.26
11 │ 5.0 4.74, set3 = 11×2 DataFrame
Row │ x y
│ Float64 Float64
─────┼──────────────────
1 │ 10.0 7.46
2 │ 8.0 6.77
3 │ 13.0 12.74
4 │ 9.0 7.11
5 │ 11.0 7.81
6 │ 14.0 8.84
7 │ 6.0 6.08
8 │ 4.0 5.39
9 │ 12.0 8.15
10 │ 7.0 6.42
11 │ 5.0 5.73, set4 = 11×2 DataFrame
Row │ x y
│ Float64 Float64
─────┼──────────────────
1 │ 8.0 6.58
2 │ 8.0 5.76
3 │ 8.0 7.71
4 │ 8.0 8.84
5 │ 8.0 8.47
6 │ 8.0 7.04
7 │ 8.0 5.25
8 │ 19.0 12.5
9 │ 8.0 5.56
10 │ 8.0 7.91
11 │ 8.0 6.89)
我们通过在数据对象中存储与四个数据集对应的四个数据框来创建 data_dfs NamedTuple。我们现在想垂直连接(堆叠)这些数据框。在 Julia 中,你可以使用 vcat 函数来完成这个操作:
julia> vcat(data_dfs.set1, data_dfs.set2, data_dfs.set3, data_dfs.set4)
44×2 DataFrame
Row │ x y
│ Float64 Float64
─────┼──────────────────
1 │ 10.0 8.04
2 │ 8.0 6.95
3 │ 13.0 7.58
: │ : :
42 │ 8.0 5.56
43 │ 8.0 7.91
44 │ 8.0 6.89
38 rows omitted
操作的结果是一个单一的数据框,源数据框堆叠在一块。唯一的问题是,我们看不到哪些行来自哪个源数据框。你可以通过传递 source 关键字参数给 vcat 来解决这个问题。如果你传递一个列名作为 source,这个列将存储给定行来自哪个数据框的编号:
julia> vcat(data_dfs.set1, data_dfs.set2, data_dfs.set3, data_dfs.set4;
source="source_id")
44×3 DataFrame
Row │ x y source_id
│ Float64 Float64 Int64
─────┼─────────────────────────────
1 │ 10.0 8.04 1
2 │ 8.0 6.95 1
3 │ 13.0 7.58 1
: │ : : :
42 │ 8.0 5.56 4
43 │ 8.0 7.91 4
44 │ 8.0 6.89 4
38 rows omitted
操作完成后,source_id 列包含从 1 到 4 的数字,显示给定行来自哪个源数据框。如果你希望为源数据框使用自定义名称,请将一个包含源列名称和分配给传递的数据框的标识符的 Pair 作为 source 关键字参数传递。以下是一个示例:
julia> vcat(data_dfs.set1, data_dfs.set2, data_dfs.set3, data_dfs.set4;
source="source_id"=>string.("set", 1:4))
44×3 DataFrame
Row │ x y source_id
│ Float64 Float64 String
─────┼─────────────────────────────
1 │ 10.0 8.04 set1
2 │ 8.0 6.95 set1
3 │ 13.0 7.58 set1
: │ : : :
42 │ 8.0 5.56 set4
43 │ 8.0 7.91 set4
44 │ 8.0 6.89 set4
38 rows omitted
如果你有很多数据框存储在一个向量中,在 vcat 调用中逐个列出它们可能不太方便。我们可以使用 reduce 函数,传递一个 vcat 作为第一个参数,后面跟着一个数据框向量,如果需要,还可以传递适当的关键字参数。在这种情况下,我们可以使用 collect 函数将 data_dfs NamedTuple 转换为一个数据框对象向量,这样我们就可以使用这个模式。以下是一个示例:
julia> reduce(vcat, collect(data_dfs);
source="source_id"=>string.("set", 1:4))
44×3 DataFrame
Row │ x y source_id
│ Float64 Float64 String
─────┼─────────────────────────────
1 │ 10.0 8.04 set1
2 │ 8.0 6.95 set1
3 │ 13.0 7.58 set1
: │ : : :
42 │ 8.0 5.56 set4
43 │ 8.0 7.91 set4
44 │ 8.0 6.89 set4
38 rows omitted
reduce 函数
reduce 函数并不仅限于 DataFrames.jl。一般来说,如果你编写 reduce (op, collection),它将执行使用给定操作符 op 对传递的集合进行归约;有关更多详细信息,请参阅 Julia 手册 tinyurl.com/3pbhaw84。
例如,如果你编写 reduce(*, [2, 3, 4]),你将得到 24,因为它是存储在向量 [2, 3, 4] 中的数字的乘积。
在我之前展示的垂直连接操作中,所有数据帧都具有相同的列名。然而,在实际操作中,你可能希望连接不满足此条件的数据帧。我们使用 cols=:union 关键字参数从传递的数据帧中创建列的并集。在必要的地方,缺失值填充在不存在某些数据帧的列中。以下列表显示了一个简单的示例。
列表 10.4 列名不匹配的垂直连接数据帧
julia> df1 = DataFrame(a=1:3, b=11:13)
3×2 DataFrame
Row │ a b
│ Int64 Int64
─────┼──────────────
1 │ 1 11
2 │ 2 12
3 │ 3 13
julia> df2 = DataFrame(a=4:6, c=24:26)
3×2 DataFrame
Row │ a c
│ Int64 Int64
─────┼──────────────
1 │ 4 24
2 │ 5 25
3 │ 6 26
julia> vcat(df1, df2)
ERROR: ArgumentError: column(s) c are missing from argument(s) 1,
and column(s) b are missing from argument(s) 2
julia> vcat(df1, df2; cols=:union)
6×3 DataFrame
Row │ a b c
│ Int64 Int64? Int64?
─────┼─────────────────────────
1 │ 1 11 missing
2 │ 2 12 missing
3 │ 3 13 missing
4 │ 4 missing 24
5 │ 5 missing 25
6 │ 6 missing 26
你可以看到,vcat(df1, df2) 抛出错误,因为传递的数据帧具有不匹配的列名。另一方面,vcat(df1, df2; cols= :union) 可以正常工作,并保留在源数据帧中传递的列的并集。请注意,由于列 c 不在 df1 中,其前三个元素在结果数据帧中填充了缺失值。同样,列 b 不在 df2 中,因此其最后三个元素在结果数据帧中填充了缺失值。
vcat 中 cols 关键字参数的选项
vcat 中的 cols 关键字参数可以采用以下值:
-
:setequal—要求所有数据帧具有相同的列名,不考虑顺序。如果它们以不同的顺序出现,则使用第一个提供的数据帧的顺序。
-
:orderequal—要求所有数据帧具有相同的列名和相同的顺序。
-
:intersect—仅保留所有提供的数据帧中存在的列。如果交集为空,则返回空数据帧。
-
:union—保留至少在一个提供的数据帧中存在的列。在必要的地方,缺失值填充在不存在某些数据帧的列中。
默认情况下,cols 关键字参数采用 :setequal 值。
练习 10.2 检查 vcat 在数据帧 df1=DataFrame(a=1, b=2) 和 df2=DataFrame(b=2, a=1) 上的结果。接下来,验证如果额外传递 cols=:orderequal 关键字参数的操作结果。
10.2.2 将表格追加到数据帧
在 10.2.1 节中,你学习了如何从多个源数据帧创建新的数据帧。接下来,我们将讨论一个类似的操作,该操作会更新数据帧。我将向你展示如何就地向现有数据帧中添加表格数据。不同之处在于,追加不会创建新的数据帧,而是修改现有的数据帧。
你可以使用 append! 函数向现有数据帧中添加数据。让我们从一个例子开始。我们将创建一个空数据帧,然后按照以下列表所示,向其中添加 data_dfs.set1 和 data_dfs.set2 数据帧。
列表 10.5 将数据框附加到数据框
julia> df_agg = DataFrame()
0×0 DataFrame
julia> append!(df_agg, data_dfs.set1)
11×2 DataFrame
Row │ x y
│ Float64 Float64
─────┼──────────────────
1 │ 10.0 8.04
2 │ 8.0 6.95
3 │ 13.0 7.58
4 │ 9.0 8.81
5 │ 11.0 8.33
6 │ 14.0 9.96
7 │ 6.0 7.24
8 │ 4.0 4.26
9 │ 12.0 10.84
10 │ 7.0 4.82
11 │ 5.0 5.68
julia> append!(df_agg, data_dfs.set2)
22×2 DataFrame
Row │ x y
│ Float64 Float64
─────┼──────────────────
1 │ 10.0 8.04
2 │ 8.0 6.95
3 │ 13.0 7.58
4 │ 9.0 8.81
5 │ 11.0 8.33
6 │ 14.0 9.96
7 │ 6.0 7.24
8 │ 4.0 4.26
9 │ 12.0 10.84
10 │ 7.0 4.82
11 │ 5.0 5.68
12 │ 10.0 9.14
13 │ 8.0 8.14
14 │ 13.0 8.74
15 │ 9.0 8.77
16 │ 11.0 9.26
17 │ 14.0 8.1
18 │ 6.0 6.13
19 │ 4.0 3.1
20 │ 12.0 9.13
21 │ 7.0 7.26
22 │ 5.0 4.74
除了就地更新传递的数据框之外,append!函数与 vcat 有类似的机制,但有以下不同之处:
-
你可以将任何遵循 Tables.jl 接口的表附加到数据框中(vcat 要求所有参数都是数据框)。
-
append!不支持
source关键字参数。如果你想在 append!中使用时有一个表示给定行来源的列,你应该在附加之前将其添加到源数据框中。 -
append!支持与 vcat 相同的
cols关键字参数。对于此参数的:setequal、:orderequal和:union值,行为相同。对于:intersect值,行为略有不同;对于此选项,附加的表包含比目标数据框更多的列,但所有在目标数据框中存在的列名必须在附加数据框中存在,并且只使用这些列。此外,支持与:intersect行为相似的:subset值,但如果附加数据框中缺少列,则对于该列,将缺失值推送到目标数据框。如列表 10.5 所示,你可以始终,无论cols关键字参数的值如何,将数据附加到没有列的数据框(DataFrame()对象),同样,DataFrame()也可以始终附加。 -
append!支持
promote关键字参数,这在 vcat 中是不需要的。此参数确定如果附加数据框中存储的值无法存储在目标数据框的列中,应该发生什么。如果promote=false,则会抛出错误。如果promote=true,则目标数据框中的列类型会改变,以便 append!操作可以成功完成。默认情况下,promote=false,除非 cols 关键字参数是:union或:subset;然后promote=true。
让我们看看前面列表中的第一点和最后一点,因为它们展示了 append!和 vcat 之间最大的差异。
我们首先检查如何将一个不是数据框的 Tables.jl 表附加到数据框中。如第 10.1 节所述,向量的 NamedTuple 是 Tables.jl 表。因此,data.set1 和 data.set2 是这样的表。因此,以下代码产生的结果与列表 10.5 的结果相同(我省略了输出以节省空间):
df_agg = DataFrame()
append!(df_agg, data.set1)
append!(df_agg, data.set2)
当你的数据可能包含缺失值时,append!函数的promote关键字通常需要。考虑以下示例:
julia> df1 = DataFrame(a=1:3, b=11:13)
3×2 DataFrame
Row │ a b
│ Int64 Int64
─────┼──────────────
1 │ 1 11
2 │ 2 12
3 │ 3 13
julia> df2 = DataFrame(a=4:6, b=[14, missing, 16])
3×2 DataFrame
Row │ a b
│ Int64 Int64?
─────┼────────────────
1 │ 4 14
2 │ 5 missing
3 │ 6 16
julia> append!(df1, df2)
┌ Error: Error adding value to column :b.
我们得到一个错误,因为 df1 数据框中的列 b 不允许在其中存储缺失值。你可以通过传递promote=true关键字参数来解决这个问题!:
julia> append!(df1, df2; promote=true)
6×2 DataFrame
Row │ a b
│ Int64 Int64?
─────┼────────────────
1 │ 1 11
2 │ 2 12
3 │ 3 13
4 │ 4 14
5 │ 5 missing
6 │ 6 16
这次,操作成功了,正如你所看到的,b 列的元素类型提升为 Union{Int, Missing},因为它的元素类型显示为 Int64?。
10.2.3 向现有数据框添加新行
append!函数在数据框中就地添加一个表。然而,通常你需要向数据框中添加一行。这个操作可以使用 push!函数来完成。这个函数的工作方式与 append!完全相同,包括允许的关键字参数。唯一的区别是,push!接受的是单行,而不是整个表。以下类型的值是有效的行:
-
DataFrameRow、NamedTuple 和字典——推送到行中的列名会被检查,并按照 cols 关键字参数规则与目标数据框的列名匹配
-
AbstractArray 和 Tuple——推送的集合必须与目标数据框中的列数相同。
让我们看看两种选项的最小示例,然后我们将转向这个功能的一个实际案例研究。
我们将以一个使用 NamedTuples 定义列名来表示行的数据推送为例:
julia> df = DataFrame()
0×0 DataFrame
julia> push!(df, (a=1, b=2))
1×2 DataFrame
Row │ a b
│ Int64 Int64
─────┼──────────────
1 │ 1 2
julia> push!(df, (a=3, b=4))
2×2 DataFrame
Row │ a b
│ Int64 Int64
─────┼──────────────
1 │ 1 2
2 │ 3 4
接下来,我们将一个向量推送到数据框中:
julia> df = DataFrame(a=Int[], b=Int[])
0×2 DataFrame
julia> push!(df, [1, 2])
1×2 DataFrame
Row │ a b
│ Int64 Int64
─────┼──────────────
1 │ 1 2
julia> push!(df, [3, 4])
2×2 DataFrame
Row │ a b
│ Int64 Int64
─────┼──────────────
1 │ 1 2
2 │ 3 4
注意,在这种情况下,由于向量不携带列名信息,我们必须在向其推送行之前用 DataFrame(a=Int[], b=Int[])初始化数据框的列。当我们向数据框推送 NamedTuples 时,只需要用 DataFrame()初始化它就足够了,因为列名可以从推送的 NamedTuples 中推断出来。
10.2.4 在数据框中存储模拟结果
将行推送到数据框在执行计算机模拟研究时很有用。数据框是一个很好的对象来存储模拟结果,原因有两点。首先,模拟通常会产生具有固定结构的数据,因此连续的模拟结果可以很容易地作为数据框中的行存储。其次,在你完成模拟后,你通常想要分析产生的数据,DataFrames.jl 提供了许多函数,使得这一部分的任务变得简单。
在本节中,我通过一个随机游走模拟的例子来展示如何进行这一操作。
二维随机游走的定义
让我们创建一个二维随机游走的简单模拟(mng.bz/E0Zl)。我们将第一维称为 x,第二维称为 y。
假设一个对象从点(0, 0)开始它的旅程,并在一步中可以向左(减少其 x 位置 1 个单位)、向右(增加其 x 位置 1 个单位)、向下(减少其 y 位置 1 个单位)或向上(增加其 y 位置 1 个单位)移动。每个方向以相同的概率随机选择。我们想要可视化这个模拟的 10 步样本。图 10.2 描述了这个过程的单步。

图 10.2 在二维随机游走单步中点的坐标可能的变化。每个方向被以相同的概率选择。
在我们的实现中,我们不会尝试提供执行此模拟的最有效方法。相反,我们的目标是学习如何与数据框(data frames)一起工作。
首先,我们创建一个函数来生成我们模拟的一次随机步:
function sim_step(current)
dx, dy = rand(((1,0), (-1,0), (0,1), (0,-1))) ❶
return (x=current.x + dx, y=current.y + dy) ❷
end
❶ 随机选择四个接受方向中的一个
❷ 返回一个包含更新位置的命名元组
步进函数假设传递给它的当前值具有 x 和 y 属性,分别提供关于对象在第一和第二维度的位置信息。它返回一个包含对象更新位置的命名元组。
值得注意的是,rand(((1,0), (-1,0), (0,1), (0,-1)))操作。rand 函数接收一个包含四个元组的元组((1,0), (-1,0), (0,1), (0,-1))。由于这个元组是一个四元素集合,rand 函数返回其元素之一,以相等的概率选择。这个语法的一个重要特性是我们没有分配任何内存来执行它,因为我们正在使用元组(参见第四章中关于元组和向量的区别的解释)。因此,它很快:
julia> using BenchmarkTools
julia> @btime rand(((1,0), (-1,0), (0,1), (0,-1)));
5.200 ns (0 allocations: 0 bytes)
接下来,请注意,dx, dy = ... 语法执行了迭代解构。rand 函数返回的元组的第一个元素被分配给 dx,第二个分配给 dy 变量——例如:
julia> dx, dy = (10, 20)
(10, 20)
julia> dx
10
julia> dy
20
在继续前进之前,让我们通过模拟快速检查 rand(((1,0), (-1,0), (0,1), (0,-1)))操作确实以相等的概率返回四个元组,通过运行 1000 万(10⁷)次随机抽取。在代码中,我在 for _ in 1:10⁷ 表达式中使用 _ 作为变量名。这种方法可以在需要 Julia 语法中的变量名,但你不想在代码中使用该变量的值时使用:
julia> using FreqTables
julia> using Random
julia> Random.seed!(1234);
julia> proptable([rand(((1,0), (-1,0), (0,1), (0,-1))) for _ in 1:10⁷])
4-element Named Vector{Float64}
Dim1 │
────────┼─────────
(-1, 0) │ 0.249893
(0, -1) │ 0.250115
(0, 1) │ 0.250009
(1, 0) │ 0.249983
我们看到所有四个值有大约四分之一的概率被观察到,正如预期的那样。我们已经在第六章中使用了 Random.seed!和 proptable 函数。
随机行走的简单模拟器
我们现在可以运行下一列表中的模拟了。
列表 10.6 两个维度的随机行走的样本模拟
julia> using Random
julia> Random.seed!(6);
julia> walk = DataFrame(x=0, y=0) ❶
1×2 DataFrame
Row │ x y
│ Int64 Int64
─────┼──────────────
1 │ 0 0
julia> for _ in 1:10 ❷
current = walk[end, :] ❸
push!(walk, sim_step(current)) ❹
end
julia> walk
11×2 DataFrame
Row │ x y
│ Int64 Int64
─────┼──────────────
1 │ 0 0 ❺
2 │ 0 1
3 │ 0 2
4 │ 0 3
5 │ 1 3
6 │ 1 4
7 │ 1 5
8 │ 1 6
9 │ 0 6
10 │ 0 7
11 │ 0 8 ❻
❶ 使用模拟的起点初始化数据表
❷ 使用下划线(_)作为迭代的变量名,因为我们稍后不需要它
❸ 获取对象的当前位置,作为表示行走数据表最后一行的 DataFrameRow
❹ 在行走数据表的末尾添加一行新数据
❺ 在实例 1 中,对象位于点(0, 0)。
❻ 在实例 11 中,移动了 10 次后,对象位于点(0, 8)。
让我们绘制模拟的结果:
julia> using Plots
julia> plot(walk.x, walk.y;
legend=false,
series_annotations=1:11, ❶
xticks=range(extrema(walk.x)...), ❷
yticks=range(extrema(walk.y)...)) ❷
❶ 在图上的数据点上添加文本注释
❷ 在图中观察值范围内的整数上打勾
图 10.3 显示了模拟的结果。

图 10.3 在这个行走数据表的可视化中,图上的每个数字都表示对象在给定位置出现的实例。对于这次模拟运行,网格上的点只访问一次。
在实例 1 中,对象位于点(0,0),然后移动了 10 次,最终在实例 11 中到达位置(0, 8)。
这个例子展示了在 plots 函数中可以使用的更多高级选项:
-
series_annotations 关键字参数允许您传递用作绘制点上的文本注释的标签。
-
xticks 和 yticks 关键字参数控制 x 轴和 y 轴刻度的位置。
注意,我希望刻度是整数,范围在行走数据框给定维度的值中。让我们一步一步地看看 range(extrema(walk .y)...)) 表达式是如何工作的。首先,我们运行 extrema 函数:
julia> extrema(walk.y)
(0, 8)
它产生一个包含在 walk.y 向量中观察到的最小值和最大值的元组。接下来,我们使用 range 函数创建一个从最小值到最大值的值范围,步长为 1。请注意,通常 range 函数期望获取两个位置参数,例如:
julia> range(1, 5)
1:5
由于极值函数返回一个包含两个元素的元组,我们需要使用 ... 符号将其展开。
模拟输出分析
让我们回到图 10.3。可能会引起你注意的是,这个图上的所有点都是不同的。在从 1 到 11 的每个实例中,点的位置都不同。这个事实会让我们感到惊讶吗?我会说会的。(我已经仔细选择了随机数生成器的种子以实现这种效果。)
注意,实例 1 和 3 中位置不同的概率是 3/4。原因是无论我们从实例 1 移动到实例 2 的方式如何,当我们处于实例 3 时,回到实例 1 的位置只有一种方式。因此,我们不会回到原地的概率是 3/4(只有四个可能的方向中的一个支持这个事件)。类似的推理适用于实例 2 和 4、3 和 5、...,最后是实例 9 和 11(总共九个事件,每个事件发生的概率都是 3/4)。由于所有九个事件都是独立的,根据概率法则,所有唯一点的概率至多是这些九个单个概率的乘积:
julia> (3/4)⁹
0.07508468627929688
我们预计这个概率在实际中会更小,因为在先前的计算中,我们只考虑了向前两步到达相同位置的情况,而在一般情况下,我们可能在实例 5 中回到实例 1 的位置(在实例 3 中没有访问过它)——例如,在移动序列(0, 1)、(1, 0)、(0, -1)、(-1, 0)之后。
让我们使用模拟来近似下一个列表中所有唯一点的概率。
列表 10.7 检查所有唯一点的行走的概率的代码
julia> function walk_unique() ❶
walk = DataFrame(x=0, y=0)
for _ in 1:10
current = walk[end, :]
push!(walk, sim_step(current))
end
return nrow(unique(walk)) == nrow(walk) ❷
end
walk_unique (generic function with 1 method)
julia> Random.seed!(2);
julia> proptable([walk_unique() for _ in 1:10⁵])
2-element Named Vector{Float64}
Dim1 │
──────┼────────
false │ 0.95744
true │ 0.04256
❶ 定义一个函数运行一次整个模拟
❷ 检查行走数据框中的所有行是否唯一
我们观察到,我们的 10 步行走由所有唯一点组成的概率是 4.2%,这低于预期的 7.5%。
与列表 10.6 中的代码相比,walk_unique 函数的新元素是 nrow(unique(walk)) == nrow(walk)表达式。它比较原始 walk 数据框和 unique(walk)数据框的行数。当应用于数据框对象时,unique 函数仅保留其中存储的唯一行。
unique 函数
unique 函数允许您去除数据框的重复行。此外,您可以可选地传递任何数据框接受的列选择器(这些选择器在第九章中已解释)作为第二个位置参数。在这种情况下,去重仅在所选列上执行。例如,unique(walk, "x")将确保 walk 数据框的 x 列中没有重复项。
如果您想在删除重复项时避免分配新的数据框,您有两个选择。首先,您可以将 view=true 关键字参数传递给 unique 函数。而不是分配新的数据框,它将返回源数据框的视图。其次,您可以使用 unique!函数,它与 unique 函数的工作方式相同,但会就地删除传递的数据框的行。
unique 函数不仅适用于数据框;它无需加载任何包即可使用,并返回由 isequal 函数确定的集合的唯一值数组。我们已经在第四章中讨论了 unique 函数。
练习 10.3 将列表 10.7 中的代码修改为,如果随机游走再次访问相同点时,只进行两步验证。验证在这种情况下,我们没有重复访问相同点的概率大约为 7.5%。
Julia 对象的序列化
在第十一章中,我们将使用本节创建的 walk 数据框。因此,在我们结束讨论之前,让我们将其保存到磁盘上。我们可以使用 CSV.jl 包来完成此操作,正如你在第八章中学到的。然而,我想向你展示 Julia 提供的另一个持久化存储选项。
Serialization 模块提供了将 Julia 对象以二进制格式保存到磁盘的功能。这与 Python 中的 pickle 功能或 R 中的 save 和 load 函数类似。您需要学习的两个函数是 serialize,它将对象写入磁盘,以及 deserialize,它从磁盘读取它到内存中:
julia> using Serialization
julia> serialize("walk.bin", walk) ❶
julia> deserialize("walk.bin") == walk ❷
true
❶ 第一个参数是我们想要写入对象的文件,第二个参数是我们想要保存的对象。
❷ deserialize 函数接受一个单一参数,即我们存储要读取数据的文件。
在代码中,我已经检查了序列化和反序列化 walk 数据框会产生相同的值。请勿删除我们创建的 walk.bin 文件,因为我们将在第十一章中使用它。
对象序列化的局限性
Julia 对象的序列化设计为短期存储功能。因此,序列化和反序列化只能由具有相同版本和相同加载包版本的 Julia 安全执行。
摘要
-
你可以从各种源值构建 DataFrame 对象,包括矩阵、向量向量、向量列表、向量命名元组、命名元组的迭代器以及以向量为键的字典。你可以将支持在 Tables.jl 包中定义的表接口的任何对象传递给 DataFrame 构造函数。这种灵活性意味着你可以在代码中轻松创建 DataFrame 对象。
-
当从矩阵构建数据框时,你需要传递列名作为第二个参数或请求自动生成列名。你可以完全控制构建的数据框,同时在不关心列的确切名称时保持便利性。
-
你可以通过将列名和列值传递给 DataFrame 构造函数来构建数据框。这是构建数据框最常用的方法之一。
-
你可以轻松地将支持 Tables.jl 接口的任何对象转换为数据框。这通常可以显著简化你的代码,因为数十个 Julia 包定义了支持 Tables.jl 接口的数据类型。
-
DataFrame 构造函数支持 copycols 关键字参数,它接受一个布尔值,允许你决定传递的数据是否应该被复制。在实际应用中,拥有这样的控制是有用的。默认情况下,数据会被复制,所以你不会遇到数据别名在代码中传播的风险。然而,如果你需要性能,或者你的计算受内存限制,你可以使用 copycols=false 来避免复制。
-
你可以使用 RCall.jl 包将 Julia 与 R 集成。当你已经在你的机器上安装了 R 并希望在 Julia 项目中使用 R 代码时,这非常有用。
-
StatsBase.jl 中的成对函数可以用来计算一个函数对所有可能成对条目集合的值。它通常用于创建数据框列的相关矩阵,在这种情况下,使用的是 cor 函数。我们可以通过使用 eachcol 函数来获取数据框列的集合。
-
你可以使用来自 Plots.jl 包的 heatmap 函数绘制矩阵的热图。这个函数通常用于显示相关矩阵。
-
你可以使用 vcat 函数垂直连接数据框。在实际应用中,当你想将多个源数据框合并为一个时,这个操作通常很有必要。
-
如果你增量收集数据,可以使用 append! 和 push! 函数动态地向数据框中添加行。append! 函数将整个表附加到数据框中,而 push! 则添加一行。这些函数在你想将模拟的结果存储在数据框中时经常被使用。
-
有时您可能想要合并具有不同列或列中不同类型值的表格数据。因此,vcat、append! 和 push! 支持 cols 关键字参数,该参数控制在没有匹配列的情况下的处理方式。此外,append! 和 push! 函数还接受 promote 关键字参数,允许您在想要向数据框添加一些与目标数据框中列的元素类型不匹配的数据时执行列类型提升。这些选项通常在处理质量较低且需要清理的现实生活数据时使用。
-
您可以使用独特的功能来去除数据框中的重复行。这个函数通常在清理数据时使用。
-
您可以通过使用序列化模块来序列化和反序列化 Julia 对象。这是一种方便的方法,用于短期持久化存储 Julia 对象。
第十一章 转换和分组数据框
本章涵盖
-
将数据框转换为其他 Julia 类型
-
编写类型稳定的代码
-
理解类型盗用
-
分组数据框对象
-
与分组数据框一起工作
在第十章中,我们回顾了从不同数据源构建 DataFrame 对象的各种方法。在本章中,我们讨论了相反的过程,并展示了如何从数据框创建其他对象(回想第九章,数据框可以是 DataFrame 或其视图,即 SubDataFrame)。你可能想在两种情况下执行此类操作。
在第一种情况下,你需要执行由不接受数据框作为输入但接受其他类型的函数提供的分析任务,因此你需要将数据框转换为预期的目标格式。一个例子是将数据框转换为矩阵,你打算在后续的线性代数操作中使用。
在第二种情况下,你想要改变数据框中存储的数据的解释方式。此类操作中最重要的是分组数据框。你可以通过在数据框上使用 groupby 函数来分组数据框,从而产生 GroupedDataFrame 对象。R 中的 dplyr 和 Python 中的 pandas 也都有分组功能。分组对象最重要的应用是使用户能够执行 split-apply-combine 转换(参见 www.jstatsoft.org/article/view/v040i01)。这种数据分析任务经常执行。我们将在剩余的章节中讨论这些操作。
然而,GroupedDataFrame 对象不仅支持 split-apply-combine,而且还能让你高效地执行诸如分组迭代、查找、重新排序和子集化等操作。所有这些任务在实践中都经常执行;让我们看看一些例子。
假设你有一个大学中学生的庞大数据库。你希望在按研究领域(按高效,即无需扫描或移动源数据,因为它可能很大)对学生进行分组后,高效地执行以下操作:
-
查找所有数学专业的学生(无需执行完整表扫描)
-
按注册学生的数量对研究领域进行排序(无需对源数据框进行排序)
-
删除所有学生人数少于 10 个的研究领域(无需更改源数据框)
你可以使用 GroupedDataFrame 对象执行这些任务。我们将在本章中讨论如何执行它们。我将本章分为两个部分:
-
第 11.1 节展示了如何将数据框转换为 Julia 中常用其他类型的值。
-
第 11.2 节解释了如何使用 groupby 函数从一个源数据框创建 GroupedDataFrame,以及如何使用它。
在讨论如何将数据框对象转换为其他类型时,我们考察了您在处理 Julia 时需要学习的两个重要概念:代码的类型稳定性和类型盗用。我在第 11.1 节中解释了这两个主题。
11.1 将数据框转换为其他值类型
在本节中,您将学习如何将数据框转换为其他值类型。当您有一个不接受数据框作为其参数的函数,但您有存储在数据框中的数据要传递给这个函数时,这种操作通常很需要。
数据框对象最常遇到的目标类型转换如下:
-
矩阵
-
向量的命名元组
-
命名元组的向量
-
数据框列的迭代器
-
数据框行的迭代器
在转换的示例中,我们将使用我们在第十章中创建的 walk 数据框。因此,我们首先需要使用 deserialize 函数从 walk.bin 文件中读取它:
julia> using DataFrames
julia> using Serialization
julia> walk = deserialize("walk.bin");
关于 Julia 中转换含义的说明
在本章中,我多次使用了转换,意味着从一个类型的对象创建另一个类型的对象。例如,我说我们将数据框转换为向量的命名元组。这个含义与对这个词的直观理解一致,并且在实践中经常遇到。这就是我决定使用它的原因。
然而,在 Julia 中,如果我们想精确一点,转换的含义更窄。Julia 定义了 convert 函数,Julia 语言的纯粹主义者可能会争论说,只有在使用这个函数时才会发生转换,无论是显式还是隐式。当您将一个值赋给数组时,就会发生 convert 函数的隐式使用。考虑以下操作:
julia> x = [1.5]
1-element Vector{Float64}:
1.5
julia> x[1] = 1
1
julia> x
1-element Vector{Float64}:
1.0
x 变量是一个 Float64 的向量。然而,在 x[1] = 1 操作中,我们将一个整数赋值给这个向量的第一个元素。正如您所看到的,1 这个整数在未要求的情况下被隐式地转换为 1.0 浮点数。
因此,严格来说,将对象转换为一种类型与构造这种类型的值是不同的。如果您想了解更多关于转换和构造之间区别的细节,请参阅 Julia 手册(mng.bz/K0Z4)。
在这本书中,为了方便起见,并且当这不会引起歧义时,我使用转换这个术语(意味着我们在调用转换方法或使用类型构造函数时创建一个特定类型的新的对象)。
11.1.1 转换为矩阵
在本节中,您将学习如何将数据框转换为矩阵。例如,如果您的数据框只包含数值,并且您想使用线性代数函数处理这些数值以检查矩阵的列是否线性无关,您可能需要执行这种转换。
将数据框转换为矩阵,可以通过传递给矩阵构造函数来实现:
julia> Matrix(walk)
11×2 Matrix{Int64}:
0 0
0 1
0 2
0 3
1 3
1 4
1 5
1 6
0 6
0 7
0 8
生成的矩阵的适当元素类型将自动检测。你可以选择自己指定它,通过将其作为参数传递给构造函数:
julia> Matrix{Any}(walk)
11×2 Matrix{Any}:
0 0
0 1
0 2
0 3
1 3
1 4
1 5
1 6
0 6
0 7
0 8
虽然可以传递矩阵的元素类型,但很少需要这样做。通常,最好依赖于自动类型检测,因为它确保操作将成功。如果你向构造函数传递了错误的元素类型,你会得到一个错误:
julia> Matrix{String}(walk)
ERROR: MethodError: Cannot `convert` an object of type Int64
to an object of type String
转换到矩阵为结果矩阵分配了新的内存,但除此之外,它相当快。当你在分析中需要将矩阵传递给函数时很有用。让我们看看一个例子。如果你将矩阵传递给 Plots.jl 的 plot 函数,它会在单个图表上绘制几条线。然而,将数据帧传递给 plot 函数是不支持的:
julia> using Plots
julia> plot(walk)
ERROR: Cannot convert DataFrame to series data for plotting
相反,你可以绘制 Matrix(walk):
julia> plot(Matrix(walk); labels=["x" "y"] , legend=:topleft)
图 11.1 显示了生成的图表。我们画了两系列数据。

图 11.1 使用两系列数据可视化 walk 数据帧。请注意,我们已经将图例移动到图表的右上角(默认情况下,图例显示在右上角,会与图表重叠)。
我们在这里使用了 plot 函数的两个关键字参数。第一个是 labels,它接受一个包含系列标签的单行矩阵——在我们的例子中,["x" "y"]。请注意,它不是一个向量(将写作["x", "y"])。我们只是在"x"和"y"之间放置了空格。第二个关键字参数是 legend,它允许我们指定图例的位置。在这种情况下,我选择将其放在图表的右上角。
11.1.2 向量命名元组的转换
在本节中,你将学习如何将数据帧转换为向量的命名元组。在实践中,有时会这样做,因为它可以提高代码的性能。在本节中,你将看到一个例子,说明这样做可能有所帮助。此外,这种转换成本很低。
转换本身很简单。只需调用以下:
julia> Tables.columntable(walk)
(x = [0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0],
y = [0, 1, 2, 3, 3, 4, 5, 6, 6, 7, 8])
这个操作不会复制源数据帧中存储的向量,并保留列名。回想一下,我们在 11.1.1 节中讨论的矩阵转换会丢失源数据帧中的列名信息,并分配新的内存。
到目前为止,你可以看到 DataFrame 和向量的 NamedTuple 看起来很相似。两者都在列中存储数据,并支持列名。让我们讨论为什么两者都很有用以及何时应该使用它们。理解这个主题是 Julia 中处理表格数据的高级知识的基本组成部分之一。它与区分 DataFrame 和向量 NamedTuple 的两个关键特性有关:
-
DataFrame 是一个类型不稳定的对象,而向量的 NamedTuple 是类型稳定的。这种区别与这两种容器类型的性能有关。
-
DataFrame 是在 DataFrames.jl 包中定义的类型,而 NamedTuple 类型是在 Julia 中定义的,无需加载任何包。DataFrames.jl 包的维护者有更大的灵活性来定义 DataFrame 对象的行为。这种区别与 类型盗用 相关。
接下来,我们将首先讨论类型稳定性问题,然后是类型盗用问题。
Julia 中的类型稳定性
我们说,如果 Julia 能够在编译时确定该代码中所有变量的类型,那么这段 Julia 代码就是 类型稳定的。如果满足这个条件,Julia 代码可以快速执行。否则,它可能运行缓慢。
我通过 DataFrame 对象的例子解释了类型稳定性的后果,因为这是本书的核心主题。如果你想了解更多关于相关性能考虑的内容,请参阅 Julia 手册中的“性能提示”部分 (mng.bz/9Vda)。
从 Julia 编译器的角度来看,DataFrame 的每一列都是一个 AbstractVector。如果你从一个数据框中提取一列,编译器无法推断其具体类型。因此,对该列的操作将会缓慢。
这里有一个例子。我们想要手动计算数据框单列元素的总和。在示例中,当我写下 1_000_000 这个字面量时,Julia 忽略了下划线,这使得阅读这个数字更容易:
julia> using BenchmarkTools
julia> function mysum(table)
s = 0 ❶
for v in table.x ❷
s += v
end
return s
end
mysum (generic function with 1 method)
julia> df = DataFrame(x=1:1_000_000);
julia> @btime mysum($df)
87.789 ms (3998948 allocations: 76.28 MiB)
500000500000
❶ 假设我们求和整数
❷ 假设表具有 x 是其列的性质
我们看到操作进行了大量的分配,但评估时间是否良好或糟糕最初是困难的。然而,让我们从我们的 df 数据框中创建一个向量 NamedTuple 并对其性能进行基准测试:
julia> tab = Tables.columntable(df);
julia> @btime mysum($tab)
153.600 μs (0 allocations: 0 bytes)
500000500000
我们看到没有分配,并且执行时间快得多,因此从性能角度来看,在 df 数据框上运行此代码不是一个好的选择。
df 和 tab 对象之间的区别是什么?正如我所说的,DataFrame 的所有列在编译器看来都是 AbstractVector。由于 AbstractVector 是一个抽象容器(回想一下第三章中关于抽象类型与具体类型之间区别的讨论),Julia 编译器不知道具有这种类型的值的实际内存布局,因此被迫使用通用的(因此较慢的)代码来处理它。在 tab 对象中,Julia 编译器知道列 x 的类型是 Vector{Int64}。这是一个具体类型,因此 Julia 可以生成用于执行计算的优化机器代码。
这可以通过在 mysum(df) 函数调用上运行 @code_warntype 宏来确认:
julia> @code_warntype mysum(df)
MethodInstance for mysum(::DataFrame)
from mysum(table) in Main at REPL[32]:1
Arguments
#self#::Core.Const(mysum)
table::DataFrame
Locals
@_3::Any
s::Any
v::Any
Body::Any
1 ─ (s = 0)
│ %2 = Base.getproperty(table, :x)::AbstractVector
│ (@_3 = Base.iterate(%2))
│ %4 = (@_3 === nothing)::Bool
│ %5 = Base.not_int(%4)::Bool
└── goto #4 if not %5
2 ... %7 = @_3::Any
│ (v = Core.getfield(%7, 1))
│ %9 = Core.getfield(%7, 2)::Any
│ (s = s + v)
│ (@_3 = Base.iterate(%2, %9))
│ %12 = (@_3 === nothing)::Bool
│ %13 = Base.not_int(%12)::Bool
└── goto #4 if not %13
3 ─ goto # 2
4 ... return s
@code_warntype 宏告诉我们编译器如何看待我们的函数调用。您不需要理解这个打印输出的所有细节。只需检查编译器是否对正在处理的数据类型有一个恰当的理解即可。一般来说,如果有问题,您会看到它以红色突出显示(本书中为粗体)。我们可以看到,我们的数据框的列 x 被视为 AbstractVector,并以红色打印,您还有几个 Any 值也被打印为红色。这是一个信号,表明代码将无法快速运行。
让我们检查 mysum(tab) 对象上的 @code_warntype 宏调用:
julia> @code_warntype mysum(tab)
MethodInstance for mysum(::NamedTuple{(:x,), Tuple{Vector{Int64}}})
from mysum(table) in Main at REPL[32]:1
Arguments
#self#::Core.Const(mysum)
table::NamedTuple{(:x,), Tuple{Vector{Int64}}}
Locals
@_3::Union{Nothing, Tuple{Int64, Int64}}
s::Int64
v::Int64
Body::Int64
1 ─ (s = 0)
│ %2 = Base.getproperty(table, :x)::Vector{Int64}
│ (@_3 = Base.iterate(%2))
│ %4 = (@_3 === nothing)::Bool
│ %5 = Base.not_int(%4)::Bool
└── goto #4 if not %5
2 ... %7 = @_3::Tuple{Int64, Int64}
│ (v = Core.getfield(%7, 1))
│ %9 = Core.getfield(%7, 2)::Int64
│ (s = s + v)
│ (@_3 = Base.iterate(%2, %9))
│ %12 = (@_3 === nothing)::Bool
│ %13 = Base.not_int(%12)::Bool
└── goto #4 if not %13
3 ─ goto # 2
4 ... return s
这次,没有红色打印的内容,我们看到 Julia 识别了所有值的类型。这意味着我们可以期待 mysum(tab) 能够快速执行。
为什么 Julia 能够对 tab 对象进行正确的类型推断?原因是存储在其中的列的名称和类型编码在其类型中:
julia> typeof(tab)
NamedTuple{(:x,), Tuple{Vector{Int64}}}
我们刚刚看到了这个事实的好处:与 tab 对象一起工作的操作是快速的。然而,必须存在一些缺点,因为 DataFrames.jl 开发者决定不使 DataFrame 对象类型稳定。有两个问题:
-
由于列名和类型是 tab 对象类型定义的一部分,它们不能动态更改。您不能添加、删除、更改类型或重命名向量的 NamedTuple 的列(如您在第四章中学到的,NamedTuple 是不可变的)。
-
编译包含许多列的向量 NamedTuple 是昂贵的。一般来说,超过 1,000 列是问题,您应该避免此类操作。
练习 11.1 测量创建一个包含 1 行和 10,000 列(仅包含 1s)的数据框所需的时间。使用由 ones(1, 10_000) 创建的矩阵作为源,并带有自动列名。接下来,测量从该数据框创建向量 NamedTuple 所需的时间。
由于这些原因,DataFrame 对象的设计开始变得有意义;它比向量的 NamedTuple 更灵活,也更符合 Julia 编译器的风格。然而,有一个问题:如何克服具有 DataFrame 类型的对象的类型不稳定性。
我们可以使用一个简单的技巧,称为 函数屏障方法 (mng.bz/jAXy) 来实现这一点。在函数内部从数据框中提取列是缓慢的,但如果您将提取的列传递给另一个函数旁边,编译器将正确地识别这个内部函数中的列类型,这样就会变得快速。您可以这样想:每次您进入一个函数,编译器都会对其参数执行类型检查。以下是一个解决 mysum 函数类型不稳定性问题的示例:
julia> function barrier_mysum2(x)
s = 0
for v in x
s += v
end
return s
end
barrier_mysum2 (generic function with 1 method)
julia> mysum2(table) = barrier_mysum2(table.x)
mysum2 (generic function with 1 method)
julia> @btime mysum2($df)
161.500 μs (1 allocation: 16 bytes)
500000500000
我们现在有非常快的执行时间,就像 mysum(tab)。我们看到只有一个分配。它与 table.x 操作相关,这是一个类型不稳定的操作。然而,一旦我们进入 barrier_mysum2 函数内部,一切就都是类型稳定的,因为 Julia 编译器正确地识别了其参数 x 的类型。理解 table.x 的类型在 mysum2 函数中是未知的,但一旦它传递给 barrier_mysum2 函数,它就在函数内部变得已知,这一点至关重要。
如你所见,解决方案相对简单。对于我们在剩余章节中将要讨论的许多操作,DataFrames.jl 会自动创建这样的内核函数,所以在实践中,大多数时候你甚至不需要考虑它。
总结来说,DataFrame 对象的设计与 DataFrames.jl 中内置的功能相结合,为您带来了 DataFrame 对象的修改灵活性和低编译成本,以及通过使用函数屏障方法计算的高执行速度。
尽管如此,有时 DataFrames.jl 提供的标准方法并不能给你预期的性能(在我的经验中,这些情况很少见)。然后,如果你有一个只有几个列的数据框,并且你知道你不想修改它,你可能想创建一个临时的向量 NamedTuple 来分析你的数据。
Julia 中的类型盗用
我们需要特殊 DataFrame 类型的第二个原因与类型盗用相关。
注意:你可以在第一次阅读这本书时跳过这一节,因为这个主题稍微复杂一些。然而,如果你想在将来自己创建 Julia 包,理解类型盗用是至关重要的。
Julia 手册将类型盗用定义为在您未定义的类型上扩展或重新定义 Base 或其他包中的方法的实践(mng.bz/WMEx)。我通过示例解释了这意味着什么以及可能产生的后果。
正如你在本章中看到的,DataFrame 类型支持许多在 Base Julia 中定义的标准函数,包括 push!、append!、vcat、unique 和 unique!。由于 DataFrame 类型在 DataFrames.jl 中定义,DataFrames.jl 的开发者可以安全地为这些函数为 DataFrame 类型定义方法。我们通过自定义定义破坏现有代码的风险为零。
现在想象一下,我们开始为向量 NamedTuple 上的 unique 定义一个特殊方法。记住,在数据框对象上运行 unique 会去重其行:
julia> df = DataFrame(a=[1, 1, 2], b=[1, 1, 2])
3×2 DataFrame
Row │ a b
│ Int64 Int64
┼──────────────
1 │ 1 1
2 │ 1 1
3 │ 2 2
julia> unique(df)
2×2 DataFrame
Row │ a b
│ Int64 Int64
─────┼──────────────
1 │ 1 1
2 │ 2 2
现在检查如果我们对一个向量 NamedTuple 运行 unique 会发生什么:
julia> tab = Tables.columntable(df)
(a = [1, 1, 2], b = [1, 1, 2])
julia> unique(tab)
1-element Vector{Vector{Int64}}:
[1, 1, 2]
我们不是去重行,而是得到 tab 的唯一列。这是 unique 在传递给 Base Julia 内置的 NamedTuple 时的默认行为。因此,我们绝对不能为向量的 NamedTuple 创建一个自定义的 unique 定义,即使我们想这么做,因为这可能会破坏依赖于 unique 在 NamedTuple 对象上默认工作方式的现有代码。
总之,由于我们定义了自己的 DataFrame 类型,我们可以自由地以任何我们喜欢的方式定义函数在其上的工作方式(甚至可以是从 Base Julia 来的函数)。另一方面,如果你在 Base Julia 中定义了一个类型和一个函数,你不允许这样做,因为这将是类型盗用。DataFrames.jl 的开发者可以以最用户友好的方式定义 DataFrame 类型值的行为了,而不受 Base Julia 中定义的默认行为的约束。
提供类型稳定表格对象的选定包
Julia 生态系统中有几种类型稳定的表格类型实现,与 DataFrames.jl 中定义的 DataFrame 类型相反。以下是一个提供此类功能的选定包列表。
TypedTables.jl 包提供了 Table 类型。对于用户来说,Table 呈现为一个命名元组的数组。表格的每一行都表示为一个命名元组。内部,Table 存储一个数组的命名元组,并且是列存储表格数据的方便结构。
另一个类似的包是 TupleVectors.jl,它定义了 TupleVector 类型。同样,对于用户来说,它看起来像是一个命名元组的向量,但它在内部存储为一个向量命名元组。这个包的一个有趣特性是它支持列嵌套;有关详细信息,请参阅github.com/cscherrer/TupleVectors.jl。
最后,StructArrays.jl 包提供了 StructArray 类型。这种类型是一个 AbstractArray,其元素可以是任何结构对象,例如命名元组(参见第四章中关于结构类型的讨论)。然而,它的内部内存布局是基于列的(结构中的每个字段都存储在一个单独的 Array 中)。
你应该选择哪个包来进行数据分析?根据我的经验,DataFrames.jl 通常会是最优选择。它目前是功能最丰富的包。此外,DataFrame 类型不稳定的一个好处是你可以轻松处理非常宽的表格,并且可以在原地修改表格。然而,当使用 DataFrames.jl 处理包含非常少行和列的数百万个表格时,你可能会遇到性能瓶颈。在这种情况下,你可以考虑使用向量的命名元组(回想一下本节中提到的,你可以使用 Tables.columntable 从数据框创建它)或者使用我列出的其中一个包(TypedTables.jl, TupleVectors.jl 或 StructArrays.jl)来提高你代码的执行速度。
11.1.3 其他常见转换
我将通过总结一些在实践中被广泛使用的更常见的转换来结束本节。除了我们已讨论的转换为矩阵和向量 NamedTuple 之外,我在表 11.1 中列出了其他常见的转换。在以下小节中,我将讨论它们,并解释何时以及为什么你可能想使用它们。在示例中,我们将继续使用本节开头反序列化的 walk 数据框。
表 11.1 数据框对象选择的转换方法。在所有示例代码中,我假设 df 是一个数据框。
| 输出值 | 含义 | 示例代码 | 分配数据内存 | 类型稳定 |
|---|---|---|---|---|
| 矩阵 | 矩阵的列是数据列。 | Matrix(df) | 是 | 是或否 |
| 向量 NamedTuple | 命名元组的每个元素是数据的一列。 | Tables.columntable(df) | 否 | 是 |
| 向量行数据 | 向量的每个元素是一行数据。 | Tables.rowtable(df) | 是 | 是 |
| NamedTuple 迭代器 | 每个迭代的元素是一行数据。 | Tables.namedtupleiterator(df) | 否 | 是 |
| DataFrameRow 集合 | 集合的每个元素是一行数据。 | eachrow(df) | 否 | 否 |
| 数据框列集合 | 集合的每个元素是一列数据。 | eachcol(df) | 否 | 否 |
| 数据框列向量 | 向量的每个元素是一列数据。 | identity.(eachcol(df)) | 否 | 是或否 |
NamedTuple 向量
我们从创建 NamedTuple 向量开始。这种转换在稍后按行处理数据时很有用:
julia> Tables.rowtable(walk)
11-element Vector{NamedTuple{(:x, :y), Tuple{Int64, Int64}}}:
(x = 0, y = 0)
(x = 0, y = 1)
(x = 0, y = 2)
(x = 0, y = 3)
(x = 1, y = 3)
(x = 1, y = 4)
(x = 1, y = 5)
(x = 1, y = 6)
(x = 0, y = 6)
(x = 0, y = 7)
(x = 0, y = 8)
这个对象的优点是它是类型稳定的,可以像其他任何向量一样稍后进行处理。缺点是对于宽表,编译成本很高,并且会分配内存。
NamedTuple 迭代器
如果你想避免内存分配,请使用 NamedTuple 的迭代器。缺点是,你不能像处理向量那样处理这个对象。你只能迭代它:
julia> nti = Tables.namedtupleiterator(walk)
Tables.NamedTupleIterator{Tables.Schema{(:x, :y), Tuple{Int64, Int64}},
Tables.RowIterator{NamedTuple{(:x, :y), Tuple{Vector{Int64},
Vector{Int64}}}}}(Tables.RowIterator{NamedTuple{(:x, :y),
Tuple{Vector{Int64}, Vector{Int64}}}}(
(x = [0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0],
y = [0, 1, 2, 3, 3, 4, 5, 6, 6, 7, 8]), 11))
julia> for v in nti
println(v)
end
(x = 0, y = 0)
(x = 0, y = 1)
(x = 0, y = 2)
(x = 0, y = 3)
(x = 1, y = 3)
(x = 1, y = 4)
(x = 1, y = 5)
(x = 1, y = 6)
(x = 0, y = 6)
(x = 0, y = 7)
(x = 0, y = 8)
数据框行和列的类型不稳定迭代器
如果我们接受处理类型不稳定的集合,我们可以调用 eachrow 和 eachcol 来获取可迭代和可索引的对象,分别从源数据框中生成行(作为 DataFrameRow)和列(作为向量):
julia> er = eachrow(walk)
11×2 DataFrameRows
Row │ x y
│ Int64 Int64
─────┼──────────────
1 │ 0 0
2 │ 0 1
3 │ 0 2
4 │ 0 3
5 │ 1 3
6 │ 1 4
7 │ 1 5
8 │ 1 6
9 │ 0 6
10 │ 0 7
11 │ 0 8
julia> er[1]
Row │ x y
│ Int64 Int64
─────┼──────────────
1 │ 0 0
julia> er[end]
DataFrameRow
Row │ x y
│ Int64 Int64
─────┼──────────────
11 │ 0 8
julia> ec = eachcol(walk)
11×2 DataFrameColumns
Row │ x y
│ Int64 Int64
─────┼──────────────
1 │ 0 0
2 │ 0 1
3 │ 0 2
4 │ 0 3
5 │ 1 3
6 │ 1 4
7 │ 1 5
8 │ 1 6
9 │ 0 6
10 │ 0 7
11 │ 0 8
julia> ec[1]
11-element Vector{Int64}:
0
0
0
0
1
1
1
1
0
0
0
julia> ec[end]
11-element Vector{Int64}:
0
1
2
3
3
4
5
6
6
7
8
使用数据框参数调用 eachrow 和 eachcol 产生的集合,不复制数据,编译成本低,但速度较慢,因为编译器在处理它们时无法生成最优代码。这个问题对于处理具有数百万行数据的数据框的 eachrow 尤为重要。为了有效地处理这种情况,引入了非分配的 Tables.namedtupleiterator 函数(你必须记住不要向它传递非常宽的表,因为这样它的编译成本会非常高)。
向量向量
我们将要讨论的最后一种转换选项是使用 identity 函数创建的 data frame 列向量(eachcol(df)),如表 6.1 所示。你可能想知道为什么我们要将 identity 函数广播到 data frame 的列上。这个操作实现了两个目标:它将 eachcol 函数返回的 DataFrameColumns 集合转换为 Vector,并且如果传递的 data frame 的列具有相同的类型,这个类型将被正确地识别为返回向量的元素类型。因此,以分配外部向量的代价(这通常应该是很低的),返回的值将便于后续操作:
julia> identity.(eachcol(walk))
2-element Vector{Vector{Int64}}:
[0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0]
[0, 1, 2, 3, 3, 4, 5, 6, 6, 7, 8]
在这种情况下,你可以看到生成的向量的元素类型被正确地识别为 Vector{Int64},因为传递的 data frame 的所有列都具有相同的元素类型。
注意,如果我们传递一个具有异构列类型的 data frame,则这种情况可能不成立:
julia> df = DataFrame(x=1:2, b=["a", "b"])
2×2 DataFrame
Row │ x b
│ Int64 String
─────┼───────────────
1 │ 1 a
2 │ 2 b
julia> identity.(eachcol(df))
2-element Vector{Vector}:
[1, 2]
["a", "b"]
这次,生成的向量的元素类型是 Vector(如第五章所述,这不是一个具体类型)。因此,后来与具有 Vector{Vector}类型的值一起工作的代码将不会是类型稳定的。因此,表 6.1 表明 identity.(eachcol(df))可以是类型稳定的,也可以不是,这取决于源 data frame 中存储的列的类型。
11.2 数据框对象分组
在本节中,我们将讨论如何从 data frame 创建 GroupedDataFrame 对象并与之交互。GroupedDataFrame 对象是源 data frame 的包装器,当你在组内对数据进行操作时非常有用。
如本章引言中所述,分组数据是执行 split-apply-combine 转换的第一步。如果你想要通过执行如分组查找或分组重排等操作来处理分组数据,这些转换非常有用。在本节中,我们将集中讨论如何直接处理 GroupedDataFrame 对象,而在接下来的章节中,你将学习如何使用它们来执行 split-apply-combine 转换。
11.2.1 准备源数据框
在本节中,我们将创建一个数据框,我们将在数据框分组的示例中使用它。我选择了我在 JuliaAcademy 的 DataFrames.jl 课程中使用的数据集(github.com/JuliaAcademy/DataFrames)。它显示了 2020 年几个城镇的降雨量。它只有 10 行,这样我们就可以更容易地理解我们执行的操作的后果。我们首先将数据读入 DataFrame:
julia> using CSV
julia> raw_data = """
city,date,rainfall
Olecko,2020-11-16,2.9
Olecko,2020-11-17,4.1
Olecko,2020-11-19,4.3
Olecko,2020-11-20,2.0
Olecko,2020-11-21,0.6
Olecko,2020-11-22,1.0
Ełk,2020-11-16,3.9
Ełk,2020-11-19,1.2
Ełk,2020-11-20,2.0
Ełk,2020-11-22,2.0
""";
julia> rainfall_df = CSV.read(IOBuffer(raw_data), DataFrame)
10×3 DataFrame
Row │ city date rainfall
│ String7 Date Float64
─────┼───────────────────────────────
1 │ Olecko 2020-11-16 2.9
2 │ Olecko 2020-11-17 4.1
3 │ Olecko 2020-11-19 4.3
4 │ Olecko 2020-11-20 2.0
5 │ Olecko 2020-11-21 0.6
6 │ Olecko 2020-11-22 1.0
7 │ Ełk 2020-11-16 3.9
8 │ Ełk 2020-11-19 1.2
9 │ Ełk 2020-11-20 2.0
10 │ Ełk 2020-11-22 2.0
这次,我们将源 CSV 数据存储在 raw_data 字符串中。接下来,我们使用 IOBuffer 函数处理它,以创建一个内存中的文件-like 对象,该对象可以被 CSV.read 函数读取。请注意,如果我们(在这种情况下,不正确地)将字符串传递给 CSV.read 函数作为第一个参数,该函数会将该参数视为文件名,这并不是我们想要的。
我们创建的 rainfall_df 数据框存储了两个城市(Olecko 和 Ełk)在 2020 年 11 月几天内的降雨量(以毫米为单位)的信息。
11.2.2 对数据框进行分组
在本节中,您将学习如何使用 groupby 函数对数据框进行分组。我们通过按城市名称对数据进行分组并存储结果在 gdf_city 分组数据框中开始我们的分析:
julia> gdf_city = groupby(rainfall_df, "city")
GroupedDataFrame with 2 groups based on key: city
First Group (6 rows): city = "Olecko"
Row │ city date rainfall
│ String7 Date Float64
─────┼───────────────────────────────
1 │ Olecko 2020-11-16 2.9
2 │ Olecko 2020-11-17 4.1
3 │ Olecko 2020-11-19 4.3
4 │ Olecko 2020-11-20 2.0
5 │ Olecko 2020-11-21 0.6
6 │ Olecko 2020-11-22 1.0
⋮
Last Group (4 rows): city = "Ełk"
Row │ city date rainfall
│ String7 Date Float64
─────┼───────────────────────────────
1 │ Ełk 2020-11-16 3.9
2 │ Ełk 2020-11-19 1.2
3 │ Ełk 2020-11-20 2.0
4 │ Ełk 2020-11-22 2.0
当我们打印 gdf_city 对象时,我们首先得到它由两个组组成的信息,以及分组键是列 city。然后显示第一组和最后一组的内 容。
您可以将任何列选择器(我们在第九章中讨论了列选择器)传递给 groupby 函数,以指定用于分组数据框的列。例如,要按除降雨量之外的所有列对 rainfall_df 数据框进行分组,请编写以下代码:
julia> gdf_city_date = groupby(rainfall_df, Not("rainfall"))
GroupedDataFrame with 10 groups based on keys: city, date
First Group (1 row): city = "Olecko", date = Dates.Date("2020-11-16")
Row │ city date rainfall
│ String7 Date Float64
─────┼───────────────────────────────
1 │ Olecko 2020-11-16 2.9
⋮
Last Group (1 row): city = "Ełk", date = Dates.Date("2020-11-22")
Row │ city date rainfall
│ String7 Date Float64
─────┼───────────────────────────────
1 │ Ełk 2020-11-22 2.0
这次,我们的数据根据存储在 city 和 date 列中的唯一值组合分成 10 组。
在对数据框进行分组时可用选项
groupby 函数有两个关键字参数,sort 和 skipmissing,您可以可选地传递这些参数以更改创建结果 GroupedDataFrame 的方式。
sort 关键字参数定义了返回的 GroupedDataFrame 中组的顺序。默认情况下(sort=nothing),此顺序是未定义的。在这种情况下,您要求 groupby 使用它支持的最快算法对数据进行分组。如果您在处理大型数据框时关心分组操作的速度,请使用此选项。如果您传递 sort=true,则组将根据分组列的值进行排序。如果您传递 sort=false,则组将按照它们在源数据框中出现的顺序创建。
skipmissing 关键字参数接受布尔值。默认情况下,它设置为 false,这意味着在结果中保留源数据框中出现的所有组。如果您传递 skipmissing=true,则具有分组列中缺失值的组将从结果中删除。
11.2.3 获取分组数据框的组键
在本节中,您将学习如何检查与 GroupedDataFrame 中每个组对应的分组键。当您想了解您的 GroupedDataFrame 存储了哪些组时,这些信息很有用。
当您处理大型 GroupedDataFrame 对象时,通常很难理解它包含哪些组。例如,我们知道 gdf_city_date 有 10 个组,但由于这会占用太多空间,我们看不到所有这些组。要获取此信息,您可以使用 keys 函数:
julia> keys(gdf_city_date)
10-element DataFrames.GroupKeys{GroupedDataFrame{DataFrame}}:
GroupKey: (city = "Olecko", date = Dates.Date("2020-11-16"))
GroupKey: (city = "Olecko", date = Dates.Date("2020-11-17"))
GroupKey: (city = "Olecko", date = Dates.Date("2020-11-19"))
GroupKey: (city = "Olecko", date = Dates.Date("2020-11-20"))
GroupKey: (city = "Olecko", date = Dates.Date("2020-11-21"))
GroupKey: (city = "Olecko", date = Dates.Date("2020-11-22"))
GroupKey: (city = "Ełk", date = Dates.Date("2020-11-16"))
GroupKey: (city = "Ełk", date = Dates.Date("2020-11-19"))
GroupKey: (city = "Ełk", date = Dates.Date("2020-11-20"))
GroupKey: (city = "Ełk", date = Dates.Date("2020-11-22"))
keys 函数返回一个存储 GroupKey 对象的向量,这些对象的行为类似于命名元组,用于保存给定组中分组列的值。如果您想在代码中稍后使用此类对象,可以轻松地将 GroupKey 对象转换为元组、命名元组或字典。以下是对第一个组进行此类转换的示例:
julia> gk1 = keys(gdf_city_date)[1]
GroupKey: (city = "Olecko", date = Dates.Date("2020-11-16"))
julia> g1_t = Tuple(gk1)
("Olecko", Dates.Date("2020-11-16"))
julia> g1_nt = NamedTuple(gk1)
(city = "Olecko", date = Dates.Date("2020-11-16"))
julia> g1_dict = Dict(gk1)
Dict{Symbol, Any} with 2 entries:
:date => Date("2020-11-16")
:city => "Olecko"
11.2.4 使用单个值索引分组数据框
在本节中,你将学习如何使用索引从一个分组数据框中获取单个组。这是你需要学习的基本操作,以便与 GroupedDataFrame 对象一起工作。
现在,你已经知道了如何获取 GroupedDataFrame 的组键信息。让我们转向获取存储在组中的数据。幸运的是,这很容易,因为 GroupedDataFrame 对象支持索引,就像向量一样。
重要的是,你可以使用正常的向量索引整数来索引 GroupedDataFrame,但你也可以传递一个 GroupKey、Tuple、NamedTuple 或字典来选择你想要选择的组。
让我们来看一个例子。以下是从我们的 gdf_city_date 对象中提取第一个组的不同等效方法:
julia> gdf_city_date[1] ❶
1×3 SubDataFrame
Row │ city date rainfall
│ String7 Date Float64
─────┼───────────────────────────────
1 │ Olecko 2020-11-16 2.9
julia> gdf_city_date[gk1] ❷
1×3 SubDataFrame
Row │ city date rainfall
│ String7 Date Float64
─────┼───────────────────────────────
1 │ Olecko 2020-11-16 2.9
julia> gdf_city_date[g1_t] ❸
1×3 SubDataFrame
Row │ city date rainfall
│ String7 Date Float64
─────┼───────────────────────────────
1 │ Olecko 2020-11-16 2.9
julia> gdf_city_date[g1_nt] ❹
1×3 SubDataFrame
Row │ city date rainfall
│ String7 Date Float64
─────┼───────────────────────────────
1 │ Olecko 2020-11-16 2.9
julia> gdf_city_date[g1_dict] ❺
1×3 SubDataFrame
Row │ city date rainfall
│ String7 Date Float64
─────┼───────────────────────────────
1 │ Olecko 2020-11-16 2.9
❶ 使用整数进行查找
❷ 使用 GroupKey 进行查找
❸ 使用元组进行查找
❹ 使用命名元组进行查找
❺ 使用字典进行查找
在代码中的所有五个索引场景中,我们获得了一个存储来自我们原始 rainfall_df 数据框的行的数据框。返回的对象是一个子数据框,这意味着,正如你在第九章中学到的,这是一个视图。这个设计选择是为了确保从 GroupedDataFrame 中获取单个组是快速的,因为它不涉及复制源数据。
为了看到更多关于组查找的例子,让我们从 gdf_city 对象中提取与城市 Olecko 对应的组(回想一下,gdf_city 是按单个列 city 分组的):
julia> gdf_city[("Olecko",)] ❶
6×3 SubDataFrame
Row │ city date rainfall
│ String7 Date Float64
─────┼───────────────────────────────
1 │ Olecko 2020-11-16 2.9
2 │ Olecko 2020-11-17 4.1
3 │ Olecko 2020-11-19 4.3
4 │ Olecko 2020-11-20 2.0
5 │ Olecko 2020-11-21 0.6
6 │ Olecko 2020-11-22 1.0
julia> gdf_city[(city="Olecko",)] ❷
6×3 SubDataFrame
Row │ city date rainfall
│ String7 Date Float64
─────┼───────────────────────────────
1 │ Olecko 2020-11-16 2.9
2 │ Olecko 2020-11-17 4.1
3 │ Olecko 2020-11-19 4.3
4 │ Olecko 2020-11-20 2.0
5 │ Olecko 2020-11-21 0.6
6 │ Olecko 2020-11-22 1.0
❶ 使用元组进行索引
❷ 使用命名元组进行索引
如你所见,当你知道你感兴趣的关键值时,查找具体的组很容易。
你可能会问为什么在像 Tuple 或 NamedTuple 这样的集合中传递标识组的值是必需的。一般来说,你可以通过多个列对数据框进行分组,所以 DataFrames.jl 需要接受值集合来标识组。正如我们讨论的那样,唯一的例外是传递一个整数作为索引,在这种情况下,它被解释为组号。
11.2.5 比较索引方法性能
在本节中,我们比较了第 11.2.4 节中讨论的索引方法的速度。但在我们继续之前,让我们看看一个简单的基准,比较在一个大型分组数据框中的查找速度。在所有以下行中,我抑制了表达式产生的值的打印,以便更容易地跟踪计时结果。
警告:这些测试是在一个拥有 32 GB RAM 的机器上运行的。如果您有更少的 RAM 并且想重现这些测试,请将 bench_df 数据框中的行数减少到,例如,10⁷。
julia> using BenchmarkTools
julia> bench_df = DataFrame(id=1:10⁸);
julia> bench_gdf = groupby(bench_df, :id);
julia> @btime groupby($bench_df, :id); ❶
248.141 ms (88 allocations: 858.31 MiB)
julia> bench_i = 1_000_000;
julia> bench_gk = keys(bench_gdf)[bench_i];
julia> bench_t = Tuple(bench_gk);
julia> bench_nt = NamedTuple(bench_gk);
julia> bench_dict = Dict(bench_gk);
julia> @btime $bench_gdf[$bench_i]; ❷
283.544 ns (7 allocations: 176 bytes)
julia> @btime $bench_gdf[$bench_gk]; ❸
336.406 ns (9 allocations: 208 bytes)
julia> @btime $bench_gdf[$bench_t]; ❹
483.505 ns (10 allocations: 224 bytes)
julia> @btime $bench_gdf[$bench_nt]; ❺
575.691 ns (12 allocations: 256 bytes)
julia> @btime $bench_gdf[$bench_dict]; ❻
678.912 ns (15 allocations: 304 bytes)
❶ 分组数据框创建的计时
❷ 使用整数进行查找的计时
❸ 使用 GroupKey 进行查找的计时
❹ 使用元组进行查找的计时
❺ 使用命名元组进行查找的计时
❻ 使用字典进行查找的计时
我有以下关于这些基准的评论:
-
当按整数列分组时,对 1 亿行进行的分组操作在几百毫秒内完成,我认为这是很快的。
-
所有索引操作都在数百纳秒的顺序上执行,这在大多数实际应用中应该足够快。
-
整数索引速度最快,其次是 GroupKey 索引和元组索引。接下来是命名元组索引,它比元组索引更昂贵,因为它还需要检查列名。使用字典索引是最慢的,因为字典是可变的,而在 Julia 中,与不可变对象相比,处理可变对象通常更慢。
-
虽然这些基准测试中看不到,但如果你使用元组、命名元组或字典索引,那么在第一次执行查找时,它比连续操作要慢。为了使元组、命名元组或字典查找的平均成本降低,DataFrames.jl 在 GroupedDataFrame 对象内部懒惰地创建一个辅助数据结构。这个操作是懒惰执行的,因为在你会仅使用整数或 GroupKeys 执行查找的情况下,这样的辅助数据结构是不需要的,所以 DataFrames.jl 默认避免其创建。
11.2.6 使用多个值索引分组数据帧
在本节中,你将使用索引从 GroupDataFrame 中选择几个组。
你现在知道,要在 GroupDataFrame 对象中执行单个组查找,你需要用单个值索引它。作为此规则的天然扩展,如果你在索引 GroupedDataFrame 时传递多个值,你将得到一个只保留所选组的 GroupedDataFrame。以下有两个例子:
julia> gdf_city[[2, 1]] ❶
GroupedDataFrame with 2 groups based on key: city
First Group (4 rows): city = "Ełk"
Row │ city date rainfall
│ String7 Date Float64
─────┼───────────────────────────────
1 │ Ełk 2020-11-16 3.9
2 │ Ełk 2020-11-19 1.2
3 │ Ełk 2020-11-20 2.0
4 │ Ełk 2020-11-22 2.0
⋮
Last Group (6 rows): city = "Olecko"
Row │ city date rainfall
│ String7 Date Float64
─────┼───────────────────────────────
1 │ Olecko 2020-11-16 2.9
2 │ Olecko 2020-11-17 4.1
3 │ Olecko 2020-11-19 4.3
4 │ Olecko 2020-11-20 2.0
5 │ Olecko 2020-11-21 0.6
6 │ Olecko 2020-11-22 1.0
julia> gdf_city[[1]] ❷
GroupedDataFrame with 1 group based on key: city
First Group (6 rows): city = "Olecko"
Row │ city date rainfall
│ String7 Date Float64
─────┼───────────────────────────────
1 │ Olecko 2020-11-16 2.9
2 │ Olecko 2020-11-17 4.1
3 │ Olecko 2020-11-19 4.3
4 │ Olecko 2020-11-20 2.0
5 │ Olecko 2020-11-21 0.6
6 │ Olecko 2020-11-22 1.0
❶ 改变 gdf_city 分组数据帧中组的顺序
❷ 创建一个包含单个组的分组数据帧
如你所见,索引规则与你学习 Julia 中的一般索引规则后预期的相同。例如,在 gdf_city[[1]]表达式中传递一个单元素向量作为索引,返回一个包含单个组的分组数据帧。这个例子使用整数来索引分组数据帧,但也可以使用 GroupKey、元组、命名元组或字典的向量。
让我在这里再次强调,GroupedDataFrame 索引不涉及复制存储在 rainfall_df 数据帧中的源数据,因此所有这些操作都很快。
总结来说,使用 GroupedDataFrame 索引,你可以轻松执行以下三个在准备数据以进行进一步操作时通常很有用的操作:
-
组查找,当索引时传递单个组索引时返回数据帧
-
组重排序,当索引时传递组索引的排列时返回分组数据帧
-
分组子集,当索引时传递分组索引的子集向量时返回分组数据帧
11.2.7 迭代分组数据帧
在本节中,我将向你展示如何迭代 GroupedDataFrame 的组。如果你想在 GroupedDataFrame 的所有组上执行相同的操作,这个操作非常有用。
由于 GroupedDataFrame 对象支持索引,因此自然地期望它们也遵循第十章中讨论的迭代接口。确实如此,这种迭代会产生表示连续组的 DataFrame。因此,你可以在理解中使用它。以下是一个示例,说明如何确定 gdf_city 分组 DataFrame 中每个组的行数:
julia> [nrow(df) for df in gdf_city]
2-element Vector{Int64}:
6
4
虽然这种迭代通常很有用,但它有一个问题。在迭代值时,我们看不到哪些键对应于它们。我们可以通过将 gdf_city 对象包装在 pairs 函数中来解决这个问题。这个函数返回一个 GroupKey 和 DataFrame 对的迭代器。以下是一个简单的示例,打印这些对:
julia> for p in pairs(gdf_city)
println(p)
end
GroupKey: (city = "Olecko",) => 6×3 SubDataFrame
Row │ city date rainfall
│ String7 Date Float64
─────┼───────────────────────────────
1 │ Olecko 2020-11-16 2.9
2 │ Olecko 2020-11-17 4.1
3 │ Olecko 2020-11-19 4.3
4 │ Olecko 2020-11-20 2.0
5 │ Olecko 2020-11-21 0.6
6 │ Olecko 2020-11-22 1.0
GroupKey: (city = "Ełk",) => 4×3 SubDataFrame
Row │ city date rainfall
│ String7 Date Float64
─────┼───────────────────────────────
1 │ Ełk 2020-11-16 3.9
2 │ Ełk 2020-11-19 1.2
3 │ Ełk 2020-11-20 2.0
4 │ Ełk 2020-11-22 2.0
现在,让我们使用 pairs 函数生成一个映射每个城市名称到该城市观测行数的字典:
julia> Dict(key.city => nrow(df) for (key, df) in pairs(gdf_city))
Dict{String7, Int64} with 2 entries:
"Ełk" => 4
"Olecko" => 6
这次,代码稍微复杂一些,所以我将一步一步地解释它(你已经在第一部分学习了所有这些语法)。在 pairs(gdf_city) 的代码部分中,(key, df) 执行了 Pair 对象的解构,并在每次迭代中将第一个元素(一个 GroupKey)分配给 key 变量,将第二个元素(一个 DataFrame)分配给 df 变量。key.city 部分从 GroupKey 对象中提取城市名称。
nrow(df) 函数生成表示给定组的 DataFrame 中的行数。最后,我们将 key.city => nrow(df) 对的迭代器传递给字典构造函数。
pairs 函数
在本节中,我们使用 pairs 函数在迭代 GroupedDataFrame 对象时生成键 => 值对。
pairs 函数也可以与不同的集合一起使用。例如,如果你向它传递一个向量,你将得到一个元素索引 => 元素值对的迭代器。一般规则是,pairs 函数返回任何将一组键映射到一组值的集合上的键 => 值对的迭代器。
现在你已经了解了与处理分组 DataFrame 相关的所有基本概念。在接下来的章节中,你将学习如何使用分组 DataFrame 执行拆分-应用-组合操作。然而,由于按组计数观测数是一个简单的拆分-应用-组合操作,我将向你展示你可以如何通过使用 combine 函数来完成它(我们将在接下来的章节中详细讨论):
julia> combine(gdf_city, nrow)
2×2 DataFrame
Row │ city nrow
│ String7 Int64
─────┼────────────────
1 │ Olecko 6
2 │ Ełk 4
在我看来,这段代码与我们在之前手动将分组 DataFrame 聚合到字典中的方法相比有以下优点:
-
它更短,更容易阅读。
-
它生成一个 DataFrame,因此如果我们想进一步处理这些数据,我们可以使用 DataFrames.jl 包的其他功能。
练习 11.2 使用 gdf_city 分组数据框,通过使用 Statistics 模块中的 mean 函数计算每个城市的平均温度。将结果存储为字典,其中键是城市名称,值是对应的平均温度。将您的结果与以下调用的输出进行比较:combine(gdf_city, :rainfall => mean)。我们将在第十二章和第十三章中讨论此类表达式的确切语法。
摘要
-
您可以轻松地将数据框转换为多种其他类型。最常见的转换之一是转换为矩阵、向量的命名元组以及命名元组的向量。当您有一个不接受 DataFrame 但需要一个其他类型值作为输入的函数时,这种转换通常需要。
-
我们称 Julia 代码为类型稳定的,如果 Julia 在编译期间可以推断出所有使用变量的类型。类型稳定的代码通常比类型不稳定的代码快。
-
在数据框上调用 eachrow 和 eachcol 函数返回的 DataFrame 对象及其类型是不稳定的。这有其好处,因为它不会产生显著的编译时间,并允许更改数据框的列。然而,这也带来一个缺点,即需要使用函数屏障方法来确保对存储在其中的数据进行操作时速度快。
-
如果在您未定义的类型上扩展或重新定义 Base Julia 或其他包中的方法,则 Julia 中会发生类型盗用。编写进行类型盗用的代码是不被鼓励的,因为它可能会破坏现有代码。
-
您可以使用 eachcol 函数创建数据框列的迭代器。当您想对数据框的连续列迭代执行操作时,会使用它。
-
您可以使用 eachrow 函数或 Tables.namedtupleiterator 函数创建数据框行的迭代器。当您想对数据框的连续行迭代执行操作时,会使用它们。
-
您可以通过使用 groupby 函数从数据框创建 GroupedDataFrame 对象。当您想按数据框中一个或多个列中存储的值对数据进行分组处理时,分组数据框非常有用。这种数据处理在实际情况中经常需要,例如,在执行 split-apply-combine 操作时。
-
GroupedDataFrame 对象是可索引和可迭代的。您可以轻松执行分组查找、重新排序或子集分组数据框中的组。当您分析存储在 GroupedDataFrame 中的数据时,需要这些操作。
-
您可以使用整数值、GroupKey、元组、命名元组或字典来索引 GroupedDataFrame 对象。这种广泛的选择是有用的,因为它允许您选择最适合您需求的一个。
-
由于在执行此类操作时,存储在父数据框中的源数据不会被复制,因此 GroupedDataFrame 对象的索引速度快。因此,您可以高效地处理具有大量组的分组数据框。
12 变异和转换数据帧
本章涵盖了
-
从 ZIP 存档中提取数据
-
添加和变异数据帧的列
-
对数据帧执行 split-apply-combine 转换
-
使用图并分析其属性
-
创建复杂图表
在第 8-11 章中,您学习了如何创建数据帧并从中提取数据。现在是时候讨论数据帧可以变异的方式了。通过数据帧变异,我的意思是使用现有列的数据创建新列。例如,您可能有一个日期列在数据帧中,并希望创建一个新列来存储从该日期提取的年份。在 DataFrames.jl 中,您可以通过两种方式实现这一目标:
-
通过向其中添加新列来就地更新源数据帧。
-
创建一个新的数据帧,只存储您将在数据分析管道中稍后需要的列。
本章涵盖了两种方法。数据帧变异是所有数据科学项目的基本步骤。正如第一章所讨论的,在摄取源数据之后,您需要对其进行准备,以便可以分析以获得见解。此数据准备过程通常涉及数据清洗和转换等任务,这些任务通常通过变异数据帧的现有列来完成。
本章解决的问题是对 GitHub 开发者的分类。我们将使用的数据来自 Benedek Rozemberczki 等人发表在“多尺度属性节点嵌入”(github.com/benedekrozemberczki/MUSAE)中的工作。共享数据集许可协议为 GPL-3.0。
将 GitHub 开发者进行分类的任务是来自复杂网络挖掘领域的典型数据科学项目。这些技术的实际商业应用是通过调查他们的朋友购买的产品来预测客户可能感兴趣购买的产品类型。
在我们的源数据中,每个开发者被分类为网络或机器学习专家。此外,我们还拥有有关哪些开发者之间有联系的信息。如果两个开发者相互在 GitHub 上关注对方,则称这两个开发者是连接的。
很自然地假设网络开发者主要与其他网络开发者连接;同样,机器学习开发者可能与其他机器学习开发者一起工作。本章的目标是检查我们的源数据是否证实了这些假设。
如本书惯例,我提供了一个数据科学项目的完整示例。因此,除了本章的核心主题数据帧变异之外,你将在数据分析的所有领域学习新事物:获取、转换和分析数据。我们将讨论如何集成 DataFrames.jl,它使你能够处理表格数据,以及 Graphs.jl 包,它提供了你可以用来分析图数据的函数。这种集成的目的是,正如你将在本章中看到的,某些数据转换在数据以表格形式表示时表达得更为自然,而其他转换在用图结构表示数据时更容易进行。
12.1 获取和加载 GitHub 开发者数据集
在本节中,你将下载并从 ZIP 文件中提取 GitHub 开发者数据集。你将把开发者的信息存储在两个数据帧中,并学习如何更新数据帧的列。所有这些任务在几乎任何数据分析项目中都是常见的任务。
GitHub 开发者数据集可在斯坦福大学大型网络数据集收藏网站下载(snap.stanford.edu/data/github-social.html)。此数据集包含有关 GitHub 开发者社交网络的信息。在此数据中,观测单位是一个 GitHub 开发者。对于每个开发者,我们都有关于他们专业化的信息,这可能是机器学习或网页开发。此外,对于每对开发者,我们知道他们是否相互关注。在数据科学中,这种数据结构被称为 图。
12.1.1 理解图
在本节中,你将学习什么是图以及如何使用图来表示 GitHub 开发者数据。一个 图 是由 节点 组成的集合,并且一些节点对可能通过 无向边 连接。在我们的数据中,单个 GitHub 开发者是一个节点,两个开发者之间的连接是一个边。当可视化时,节点通常表示为点,边表示为连接这些点的线。图 12.1 展示了一个表示五个开发者的示例小图,该图来自 GitHub 开发者的社交网络。

图 12.1 在这个包含五个 GitHub 开发者的图中,每个开发者是一个编号的节点(点),开发者之间的每个连接是一个边(线)。
此图由五个节点组成。对于每个节点,我展示了开发者的 GitHub 名称。在 Graphs.jl 包中,节点被分配了数字。在 Julia 中,我们使用基于 1 的索引(详细信息请参阅第四章),因此节点从 1 到 5 编号。
图中的节点通过边连接。在这个例子中,边是通过连接节点的线条表示的。通常,边由表示它们连接的节点的数字对描述;例如,边 (1, 2) 表示节点 1 和 2 通过边连接。在我们的图中,我们有六个边:(1, 2)、(1, 4)、(2, 3)、(3, 4)、(3, 5) 和 (4, 5)。
在分析图时,我们经常谈论一个特定节点的邻居集合。邻居定义为与被分析节点通过边连接的节点。例如,在图 12.1 中,节点 4 通过边与节点 1、3 和 5 连接,因此集合 {1, 3, 5} 是节点 4 的邻域。此外,对于每个节点,我们定义其 度 为连接到它的边的数量。在节点 4 的例子中,其度数为 3。
回到我们涉及 GitHub 开发者图的问题,我们想看看通过检查一个节点(GitHub 开发者)的邻域,我们是否能够预测这位开发者是机器学习或网络专家。例如,在图 12.1 中,节点 4(dead-horse GitHub 用户)与节点 1、3 和 5(Teachat8、Jasondu 和 Shawflying GitHub 用户)连接,这些是它的邻域。我们想检查通过学习节点 1、3 和 5 是否代表网络或机器学习开发者,我们是否可以预测节点 4 的类型。
我刚才描述的问题在图挖掘领域是一个标准任务,称为 节点分类。在本章中,我将向你展示如何对这个问题进行简单分析,主要关注使用 DataFrames.jl 处理数据。如果你想更深入地探索分析图数据,你可以查看我与 Paweł Prałat 和 François Théberge 合著的 Mining Complex Networks(CBC Press,2021),www.ryerson.ca/mining-complex-networks/。这本书附有所有示例的源代码,包括 Julia 和 Python。
12.1.2 从网络获取 GitHub 开发者数据
在本节中,我们将下载 GitHub 开发者数据并检查下载的文件是否正确。这次,源文件 (snap.stanford.edu/data/git_web_ml.zip) 是一个 ZIP 归档,因此你还将学习如何处理此类文件。由于 ZIP 归档是二进制文件,出于安全考虑,我们将验证文件的 SHA-256 哈希(本节后面将解释),以确保正确获取。
在下面的列表中,我们使用第六章中更详细描述的函数下载数据。
列表 12.1 下载并检查 git_web_ml.zip 文件
julia> import Downloads
julia> using SHA
julia> git_zip = "git_web_ml.zip"
"git_web_ml.zip"
julia> if !isfile(git_zip) ❶
Downloads.download("https://snap.stanford.edu/data/" *
"git_web_ml.zip",
git_zip)
end
julia> isfile(git_zip) ❷
true
julia> open(sha256, git_zip) == [0x56, 0xc0, 0xc1, 0xc2, ❸
0xc4, 0x60, 0xdc, 0x4c, ❸
0x7b, 0xf8, 0x93, 0x57, ❸
0xb1, 0xfe, 0xc0, 0x20, ❸
0xf4, 0x5e, 0x2e, 0xce, ❸
0xba, 0xb8, 0x1d, 0x13, ❸
0x1d, 0x07, 0x3b, 0x10, ❸
0xe2, 0x8e, 0xc0, 0x31] ❸
true
❶ 仅在当前工作目录中不存在该文件时下载文件
❷ 确保文件已成功下载
❸ 将 sha256 函数应用于下载的文件以计算其 SHA-256 哈希,并将其与我在我的机器上计算的一个参考向量进行比较
让我们关注 open(sha256, git_zip) 操作。这是一段简短的代码,但下面做了很多事情。在这个模式中,我们向 open 函数传递两个参数。第一个是我们想要应用于文件的函数,第二个是我们想要处理的文件名。图 12.2 列出了 Julia 执行此操作时执行的步骤。

图 12.2 open(sha256, git_zip) 操作执行的步骤。当 open 函数以 sha256 函数作为第一个参数传递时,保证在操作完成后关闭打开的流,并返回 sha256 函数产生的值。
将作为 open 函数的第一个参数传递的函数必须接受新打开文件的句柄作为其唯一参数。这种情况适用于
sha256 函数,它可以接受这个文件句柄并计算从其中读取的数据的 SHA-256 哈希值。值得注意的是,这个计算不需要将整个文件读入 RAM,这允许处理非常大的文件。此外,重要的是要知道,如果 open 函数将其第一个参数作为函数,那么 open 将返回该函数返回的值并自动关闭它打开的流。这种行为很有用,因为程序员不需要记住手动关闭流。
SHA-256 哈希
SHA-256 是由美国国家安全局设计的加密哈希函数。SHA-256 算法接收一个字节流并返回其 256 位的表示。其理念是,如果你有两个不同的源流,它们具有相同的 SHA-256 表示的可能性非常低。此外,该算法被称为 单向,这意味着如果你有数据的 256 位表示,很难找到输入数据,其 SHA-256 哈希与你的匹配。如果你想了解更多关于这个主题的信息,请查看 David Wong 的《Real-World Cryptography》(Manning,2021,www.manning.com/books/real-world-cryptography)。
SHA-256 哈希的一个常见用途是验证从网络获取的数据是否正确下载。如果你有数据的预期 SHA-256 哈希,并且它与你在获取的数据上计算出的哈希匹配,那么数据很可能没有被损坏。
在 Julia 中,SHA 模块中的 sha256 函数返回一个包含应用 SHA-256 算法到传递数据的结果的 32 元素 Vector {UInt8}。
12.1.3 实现从 ZIP 文件中提取数据的函数
现在我们已经下载了 git_web_ml.zip 归档,我们可以将我们稍后要处理的数据读入一个数据框中。在本节中,我们将创建一个执行此操作的函数。
作为第一步,我们使用 ZipFile.jl 包打开 ZIP 归档:
julia> import ZipFile
julia> git_archive = ZipFile.Reader(git_zip)
ZipFile.Reader for IOStream(<file git_web_ml.zip>) containing 6 files:
uncompressedsize method mtime name
----------------------------------------------
0 Store 2019-10-03 21-49 git_web_ml/
3306139 Deflate 2019-09-20 22-39 git_web_ml/musae_git_edges.csv
4380176 Deflate 2019-09-20 22-39 git_web_ml/musae_git_features.json
676528 Deflate 2019-09-20 22-39 git_web_ml/musae_git_target.csv
485 Deflate 2019-10-03 21-44 git_web_ml/citing.txt
881 Deflate 2019-10-03 21-49 git_web_ml/README.txt
git_archive变量绑定到一个对象,允许我们从存档中读取数据。我们可以看到存档中有五个文件。我们感兴趣的是musae_git_edges.csv和musae_git_target.csv。这些是 CSV 文件,因此我们将使用 CSV.jl 包来读取它们。
在我们继续之前,让我们看看ZipFile.Reader的结构。每个ZipFile.Reader对象都有一个files属性,它是一个包含其中存储的文件的 Vector。让我们调查git_archive变量中的这个属性:
julia> git_archive.files
6-element Vector{ZipFile.ReadableFile}:
ZipFile.ReadableFile(name=git_web_ml/, method=Store,
uncompresssedsize=0, compressedsize=0, mtime=1.57013214e9)
⋮
ZipFile.ReadableFile(name=git_web_ml/README.txt, method=Deflate,
uncompresssedsize=881, compressedsize=479, mtime=1.57013214e9)
在这种情况下,我们有元素:一个目录和五个文件。每个存储的文件都有几个属性。我们感兴趣的是存储文件名的name属性。让我们检查git_archive中存储的第二个文件:
julia> git_archive.files[2].name
"git_web_ml/musae_git_edges.csv"
首先,我们编写一个辅助函数,该函数从存档中存储的 CSV 文件创建一个 DataFrame。在列表 12.2 中,ingest_to_df函数接受两个参数。第一个是存档,它应该是一个打开的 ZipFile.Reader 对象,就像绑定到git_archive变量上的那样。第二个参数是filename,这是我们想要从存档中提取的文件名。这个名称包括文件的完整路径,因此存档中的所有文件都是唯一标识的。
列表 12.2 从 ZIP 存档中提取 CSV 文件到数据框的函数
function ingest_to_df(archive::ZipFile.Reader, filename::AbstractString)
idx = only(findall(x -> x.name == filename, archive.files))
return CSV.read(read(archive.files[idx]), DataFrame)
end
让我们逐步查看这个函数的功能。findall(x -> x.name == filename, archive.files)调用查找所有名称与filename变量匹配的文件,并将它们作为向量返回。findall函数接受两个参数。第一个是一个函数,指定我们想要检查的条件(在这种情况下,文件名是否与filename匹配)。第二个参数是一个集合;从这个集合中,我们想要找到函数作为第一个参数传递时返回为真的元素。
findall函数返回一个索引向量,指向满足检查条件的集合。以下有两个findall调用的示例:
julia> findall(x -> x.name == "git_web_ml/musae_git_edges.csv",
git_archive.files)
1-element Vector{Int64}:
2
julia> findall(x -> x.name == "", git_archive.files)
Int64[]
在第一次调用findall时,我们了解到在索引 2 处有一个文件,其名称为git_web_ml/musae_git_edges.csv。在第二次调用中,我们发现没有文件名称匹配""。
在ingest_to_df函数中,我们期望传入的文件名与我们的存档中恰好一个文件完全匹配。因此,我们使用唯一函数来获取该文件索引作为整数。如果没有恰好一个匹配项,则会抛出错误:
julia> only(findall(x -> x.name == "git_web_ml/musae_git_edges.csv",
git_archive.files))
2
julia> only(findall(x -> x.name == "", git_archive.files))
ERROR: ArgumentError: Collection is empty, must contain exactly 1 element
将only与findall结合使用是一种常见的模式,它提供了一种安全的方式来检查集合中是否恰好有一个元素满足某种条件。
在 ingest_to_df 函数中,我们将找到的文件索引存储在 idx 变量中。接下来,我们调用 read(archive.files[idx])将文件解压缩到 Vector{UInt8}对象中。重要的是要记住,从存档中读取数据会消耗它。如果我们对同一个文件对象调用 read 函数,我们会得到空的 UInt8 向量数组。这与第七章中讨论的相同模式相同,当时我们将 HTTP.get 查询的结果转换为 String。
接下来,这个 Vector{UInt8}对象被传递给 CSV.read 函数,该函数将传递的数据解析为 CSV 并返回一个 DataFrame。注意,之前我们使用了 CSV.read 函数,传递给它一个包含要解析的文件名的字符串。这次,我们直接传递一个字节数组,并且它被正确处理。这种方法很有用,因为我们不需要在将 CSV 文件读入 DataFrame 之前将其保存到磁盘上。
12.1.4 将 GitHub 开发者数据读入 DataFrame
在本节中,我们将使用第 12.2 节中定义的 ingest_to_df 函数将数据读入 DataFrame。
创建 DataFrame
在 12.3 节中,我们创建了两个 DataFrame。第一个是 edges_df。通过调用 summary 和 describe 函数,我们了解到这个 DataFrame 有 289,003 行和两列:id_1 和 id_2。这个 DataFrame 的行代表我们 GitHub 开发者图中的边。第二个 DataFrame 是 classes_df。它有 37,700 行和三列:id、name 和 ml_target。这个 DataFrame 的一行代表一个开发者的信息。
我们感兴趣的关键节点特征存储在 ml_target 列中。它有两个值,0 和 1,其中 0 表示网页开发者,1 表示机器学习开发者。观察发现,数据集中只有大约 25%的开发者是机器学习专家。
列表 12.3 构建边和节点属性 DataFrame
julia> using CSV
julia> using DataFrames
julia> edges_df = ingest_to_df(git_archive,
"git_web_ml/musae_git_edges.csv"); ❶
julia> classes_df = ingest_to_df(git_archive,
"git_web_ml/musae_git_target.csv"); ❷
julia> close(git_archive) ❸
julia> summary(edges_df)
"289003×2 DataFrame"
julia> describe(edges_df, :min, :max, :mean, :nmissing, :eltype) ❹
2×6 DataFrame
Row │ variable min max mean nmissing eltype
│ Symbol Int64 Int64 Float64 Int64 DataType
─────┼─────────────────────────────────────────────────────
1 │ id_1 0 37694 14812.6 0 Int64
2 │ id_2 16 37699 23778.8 0 Int64
julia> summary(classes_df)
"37700×3 DataFrame"
julia> describe(classes_df, :min, :max, :mean, :nmissing, :eltype) #D
3×6 DataFrame
Row │ variable min max mean nmissing eltype
│ Symbol Any Any Union... Int64 DataType
─────┼─────────────────────────────────────────────────────────────────
1 │ id 0 37699 18849.5 0 Int64
2 │ name 007arunwilson timqian 0 String31
3 │ ml_target 0 1 0.258329 0 Int64
❶ 包含 GitHub 开发者图边的 DataFrame
❷ 包含我们分类问题目标的 DataFrame
❸ 在从 ZipFile.Reader 对象获取数据后关闭它
❹ 传递我们感兴趣的汇总统计列表
在我们继续分析之前,让我们看看 describe 调用。我在第八章介绍了这个函数,你了解到它可以传递比我们想要描述的 DataFrame 更多的参数。在这个例子中,我将计算的统计信息限制为我们分析中感兴趣的那些:最小值、最大值、平均值、缺失值的数量和列的元素类型。如果你想看到所有默认的列统计信息,你可以调用 describe(edges_df)和 describe(classes_df)。
到目前为止,如果你有一个 GitHub 账户,你可能想知道你是否包含在我们分析的数据库中。你可以通过使用本章之前使用的 findall 函数来检查。这里,我们使用 GitHub 上的 my bkamins 名字:
julia> findall(n -> n == "bkamins", classes_df.name)
Int64[]
The returned vector is empty, which means that my name is not included in this data. Now let’s look for StefanKarpinski (one of the creators of the Julia language):
julia> findall(n -> n == "StefanKarpinski", classes_df.name)
1-element Vector{Int64}:
1359
julia> classes_df[findall(n -> n == "StefanKarpinski", classes_df.name), :]
1×3 DataFrame
Row │ id name ml_target
│ Int64 String31 Int64
─────┼───────────────────────────────────
1 │ 1358 StefanKarpinski 1
This time, the check succeeds. Note that the id of StefanKarpinski is 1358, but it is row number 1359 in our data frame. Let’s fix this off-by-one issue.
Using broadcasting to update the contents of data frames
An important feature we learn from listing 12.3 is that the developer’s identifier (columns id_1 and id_2 in edges_df and column id in classes_df) starts indexing with 0. Let’s increase all the indices by 1 so that they start from 1. This is needed because in section 12.2, we will use these edges to create a graph with the Graphs.jl package, and in this package, nodes in a graph use 1-based indexing, just like standard arrays in Julia. We accomplish the update by using broadcasting:
julia> edges_df .+= 1
289003×2 DataFrame
Row │ id_1 id_2
│ Int64 Int64
────────┼──────────────
1 │ 1 23978
2 │ 2 34527
3 │ 2 2371
: │ : :
289001 │ 37645 2348
289002 │ 25880 2348
289003 │ 25617 2348
288997 rows omitted
julia> classes_df.id .+= 1
37700-element SentinelArrays.ChainedVector{Int64, Vector{Int64}}:
1
2
3
⋮
37698
37699
37700
The edges_df .+= 1 example shows that when broadcasting a data frame as a whole, it is treated as a two-dimensional object. Therefore, in this case, the operation gives the same result as we would get if we had a matrix instead of a data frame: we have incremented each cell in the data frame by 1.
The classes_df.id .+= 1 example shows that if you get a single column from a data frame, you can update it exactly as you update a vector—in this case, by also incrementing all its elements by 1.
A rule to remember is that broadcasting data frames works in the same way as for other arrays. Therefore, everything that you learned about broadcasting in Julia in general (covered in chapter 5) applies to data frame objects.
Let’s look at a few more examples using a smaller df data frame (not related to our GitHub example) because it is easier to follow visually:
julia> df = DataFrame(a=1:3, b=[4, missing, 5]) ❶
3×2 DataFrame
Row │ a b
│ Int64 Int64?
─────┼────────────────
1 │ 1 4
2 │ 2 missing
3 │ 3 5
julia> df .^ 2 ❷
3×2 DataFrame
Row │ a b
│ Int64 Int64?
─────┼────────────────
1 │ 1 16
2 │ 4 missing
3 │ 9 25
julia> coalesce.(df, 0) ❸
3×2 DataFrame
Row │ a b
│ Int64 Int64
─────┼──────────────
1 │ 1 4
2 │ 2 0
3 │ 3 5
julia> df .+ [10, 11, 12] ❹
3×2 DataFrame
Row │ a b
│ Int64 Int64?
─────┼────────────────
1 │ 11 14
2 │ 13 missing
3 │ 15 17
julia> df .+ [10 11] ❺
3×2 DataFrame
Row │ a b
│ Int64 Int64?
─────┼────────────────
1 │ 11 15
2 │ 12 missing
3 │ 13 16
❶ 创建数据框
❷ 将数据框中所有元素平方
❸ 将数据框中所有缺失元素替换为 0(coalesce 函数在第五章中已解释)
❹ 将向量[10 11, 12]添加到数据框的每一列
❺ 将一行的矩阵[10 11]添加到数据框的每一行
Going back to the GitHub example, our developer identifiers now start from 1. There is more to it. All developers have unique numbers, and the classes_df data frame stores them in sorted order, starting from developer 1 and ending with developer 37700. We can easily check this by using the axes function you learned about in chapter 4:
julia> classes_df.id == axes(classes_df, 1)
true
The previous classes_df.id .+= 1 example showed how you can update the existing column of a data frame by using broadcasting. However, instead of using classes_df.id, you could have written classes_df[:, :id] .+= 1 or classes _df[!, :id] .+= 1. You could also use "id" here instead of :id. A natural question is whether there is a difference between using : and ! as a row selector in these two assignments. Indeed, there is a subtle one. The classes_df[:, :id] .+= 1 operation updates the :id column in place, while classes_df[!, :id] .+= 1 allocates a new column in the data frame.
如果你想知道在哪些情况下这个选择会有所不同,请考虑以下示例。同样,我使用一个新的、小的 df 数据框,它与 GitHub 案例研究无关,以便更容易检查我们执行的操作的结果:
julia> df = DataFrame(a=1:3, b=1:3)
3×2 DataFrame
Row │ a b
│ Int64 Int64
─────┼───────────────
1 │ 1 1
2 │ 2 2
3 │ 3 3
julia> df[!, :a] .= "x"
3-element Vector{String}:
"x"
"x"
"x"
julia> df[:, :b] .= "x"
ERROR: MethodError: Cannot `convert` an object of type String
to an object of type Int64
julia> df
3×2 DataFrame
Row │ a b
│ String Int64
─────┼───────────────
1 │ x 1
2 │ x 2
3 │ x 3
在这个示例中,我们看到 df[!, :a] .= "x"之所以有效,是因为它用新数据(df.a .= "x"调用将是等效的)替换了列。然而,df[:, :a] .= "x"失败,因为它试图就地更新现有的列,而我们无法将字符串分配给整数向量。
我们讨论的将赋值操作(.=运算符)广播到数据框列的相同模式也适用于对现有列的标准赋值(=运算符)。以下是一个示例:
julia> df = DataFrame(a=1:3, b=1:3, c=1:3)
3×3 DataFrame
Row │ a b c
│ Int64 Int64 Int64
─────┼─────────────────────
1 │ 1 1 1
2 │ 2 2 2
3 │ 3 3 3
julia> df[!, :a] = ["x", "y", "z"]
3-element Vector{String}:
"x"
"y"
"z"
julia> df[:, :b] = ["x", "y", "z"]
ERROR: MethodError: Cannot `convert` an object of type String
to an object of type Int64
julia> df[:, :c] = [11, 12, 13]
3-element Vector{Int64}:
11
12
13
julia> df
3×3 DataFrame
Row │ a b c
│ String Int64 Int64
─────┼──────────────────────
1 │ x 1 11
2 │ y 2 12
3 │ z 3 13
在这个示例中,我们可以看到 df[!, :a] = ["x", "y", "z"]操作(或等价地,df.a = ["x", "y", "z"])之所以有效,是因为它替换了列。df[:, :b] = ["x", "y", "z"]赋值失败,因为它是对现有列的就地操作,我们无法将字符串转换为整数。然而,df[:, :c] = [11, 12, 13]之所以有效,是因为我们向 c 列分配了一个整数向量。
12.2 计算额外的节点特征
在本节中,你将学习如何通过使用 Graphs.jl 包中定义的 SimpleGraph 类型将存储在数据框中的表格数据与图集成。我们将从存储在 edges_df 数据框中的边列表创建一个图。接下来,使用 Graphs.jl 包,我们将计算图节点的几个特征,并将它们作为新列添加到 classes_df 数据框中。如果你将来会处理社交媒体数据,了解如何使用 Graphs.jl 包来分析它将是有用的。
Graphs.jl 包
本节仅介绍了 Graphs.jl 包提供的有限功能集。如果你想了解更多关于在 Julia 中使用图的信息,请参阅包文档(juliagraphs.org/Graphs.jl/dev/)。在这里,我只是评论说它支持所有在处理图时有用的典型功能,包括图遍历、计算节点距离和中心性度量。
12.2.1 创建 SimpleGraph 对象
在本节中,我们将创建一个 Graph 对象。这个对象很有用,因为 Graphs.jl 包提供了多个函数,这些函数将允许你以后高效地查询此类对象的相关属性——例如,从图中查询特定节点的邻居。
在列表 12.4 中,我们使用 Graphs.jl 包来处理图。首先,我们使用 SimpleGraph 函数创建一个空图,然后迭代 edges_df 数据框的行,使用 add_edge!函数向其中添加边。接下来,我们检查图中的边和节点数量。它们与 edges_df 和 classes_df 数据框中的行数分别一致,正如预期的那样。在 Graphs.jl 中,节点始终使用连续整数编号,从 1 开始。
列表 12.4 从边列表创建图
julia> using Graphs
julia> gh = SimpleGraph(nrow(classes_df)) ❶
{37700, 0} undirected simple Int64 graph
julia> for (srt, dst) in eachrow(edges_df) ❷
add_edge!(gh, srt, dst)
end
julia> gh
{37700, 289003} undirected simple Int64 graph
julia> ne(gh) ❸
289003
julia> nv(gh) ❹
37700
❶ 创建一个包含 37,700 个节点但没有边的图
❷ 通过迭代 edges_df 数据帧的行来向图中添加边
❸ 获取图中边的数量
❹ 获取图中节点数(也称为顶点数)
让我们看看 for (src, dst) in eachrow(edges_df) 表达式。回想一下,edges_df 有两列。这意味着这个数据帧的每一行都是一个包含两个元素的 DataFrameRow(我们在第九章讨论了 DataFrameRow 对象)。当我们迭代这两个元素对象时,我们可以通过使用元组语法自动将它们解构为两个变量(我们在第十章讨论了解构)。在这种情况下,这两个变量是 src 和 dst(它们需要用括号括起来)。在代码中,我使用 src 和 dst 变量名,因为它们在 Graphs.jl 包内部使用。然而,请记住,我们的图是无向的,所以边没有方向。
这里有一个使用矩阵而不是数据帧进行迭代的另一个例子(以表明这是一个你可以使用的通用模式):
julia> mat = [1 2; 3 4; 5 6]
3×2 Matrix{Int64}:
1 2
3 4
5 6
julia> for (x1, x2) in eachrow(mat)
@show x1, x2
end
(x1, x2) = (1, 2)
(x1, x2) = (3, 4)
(x1, x2) = (5, 6)
事实上,我们看到 x1 和 x2 变量获取了矩阵迭代行的第一个和第二个元素。这个例子使用了 @show 宏,它在调试中很有用,因为它显示了传递给它的表达式及其值。
12.2.2 使用 Graphs.jl 包计算节点的特征
在本节中,使用 gh 图,我们将使用 Graphs.jl 库的功能来计算其节点的某些特征。我们从节点度开始,可以使用 degree 函数获得:
julia> degree(gh)
37700-element Vector{Int64}:
1
8
1
⋮
4
3
4
我们可以看到第一个节点有一个邻居,第二个节点有八个邻居,以此类推。让我们在 classes_df 数据帧中创建一个 deg 列,以存储这个节点度信息:
julia> classes_df.deg = degree(gh)
37700-element Vector{Int64}:
1
8
1
⋮
4
3
4
我们可以执行这个赋值操作,因为我们确信 classes_df 数据帧按升序存储开发者,从 1 开始,到 37700 结束。如果不是这样,我们就必须执行一个连接操作,以正确地将开发者与其功能匹配。我们在第十三章讨论了连接。
创建列的语法与更新现有列的语法相同。因此,你也可以写成 classes_df[!, :deg] = degree(gh) 或 classes_df[:, :deg] = degree(gh) 来向数据帧添加列。与更新列一样,使用 ! 和 : 行选择器之间有一个区别。! 行选择器将传递的向量存储在数据帧中而不进行复制(当我们更新现有列时也会发生相同的情况)。: 行选择器创建传递向量的副本。以下是一个显示这种差异的例子:
julia> df = DataFrame()
0×0 DataFrame
julia> x = [1, 2, 3]
3-element Vector{Int64}:
1
2
3
julia> df[!, :x1] = x
3-element Vector{Int64}:
1
2
3
julia> df[:, :x2] = x
3-element Vector{Int64}:
1
2
3
julia> df
3×2 DataFrame
Row │ x1 x2
│ Int64 Int64
─────┼──────────────
1 │ 1 1
2 │ 2 2
3 │ 3 3
julia> df.x1 === x
true
julia> df.x2 === x
false
julia> df.x2 == x
true
x1 列是在不复制的情况下创建的,因此它存储与 x 向量相同的向量。x2 列是通过复制创建的,因此它存储具有相同内容但内存中不同位置的向量。因此,如果我们后来更改了 x 向量的内容,x1 列的内容将发生变化,但 x2 列的内容将不受影响。
为了完整地阐述,让我提一下,您也可以通过使用广播赋值来创建列。以下是一个使用前一个示例中创建的 df 数据框的示例:
julia> df.x3 .= 1
3-element Vector{Int64}:
1
1
1
julia> df
3×3 DataFrame
Row │ x1 x2 x3
│ Int64 Int64 Int64
─────┼─────────────────────
1 │ 1 1 1
2 │ 2 2 1
3 │ 3 3 1
12.2.3 计算一个节点的网络和机器学习邻居数量
在本节中,我们将计算 GitHub 开发者数据框的另外两个特征。我们想要计算节点网络中是网络开发者的邻居数量以及是机器学习开发者的邻居数量。这个操作比我们在本书中通常执行的操作稍微复杂一些,并且它使用了您已经学习到的 Julia 语言中的许多特性。
迭代图的边
在我们执行此操作之前,让我们看看 Graphs.jl 库中的 edges 函数,它返回一个图边的迭代器:
julia> edges(gh)
SimpleEdgeIter 289003
让我们检查它的第一个元素:
julia> e1 = first(edges(gh))
Edge 1 => 23978
julia> dump(e1)
Graphs.SimpleGraphs.SimpleEdge{Int64}
src: Int64 1
dst: Int64 23978
julia> e1.src
1
julia> e1.dst
23978
我们可以看到,e1 对象代表图中的一个单边。使用 dump 函数,我们检查其结构并了解到它有两个字段,src 和 dst,然后我们检查它们可以使用属性访问语法访问。
我们知道如何处理 gh 图的边,所以让我们转向计算是网络开发者还是机器学习开发者的节点邻居数量的函数。
定义一个计算节点邻居数量的函数
在列表 12.5 中,deg_class函数接受两个参数:一个 gh 图和一个 0-1 向量类,该类指示开发者是否与机器学习或网络工作。
使用 zeros 函数,我们创建了将存储机器学习和网络开发者邻居数量的向量。这些向量使用 Int 参数初始化为整数零,并且长度等于我们数据中的开发者数量,即类向量的长度。
接下来,我们迭代图的边。对于单个边,我们将分配给该边两端的开发者的数字存储在 a 和 b 变量中。然后,我们使用 class[b] == 1 条件检查 b 开发者是否与机器学习工作。如果是这种情况,我们将开发者 a 的机器学习邻居数量增加一个;否则,我们为开发者 a 的网络开发者数量执行相同的操作。然后我们执行相同的操作,但检查开发者 a 的类型并更新 b 的邻居计数信息。最后,我们返回一个包含我们创建的两个向量的元组。
列表 12.5 计算节点邻居数量的函数
function deg_class(gh, class)
deg_ml = zeros(Int, length(class)) ❶
deg_web = zeros(Int, length(class)) ❶
for edge in edges(gh) ❷
a, b = edge.src, edge.dst ❸
if class[b] == 1 ❹
deg_ml[a] += 1 ❹
else ❹
deg_web[a] += 1 ❹
end
if class[a] == 1 ❺
deg_ml[b] += 1 ❺
else ❺
deg_web[b] += 1 ❺
end
end
return (deg_ml, deg_web)
end
❶ 使用整数零初始化向量
❷ 迭代图的边
❸ 将迭代边的两端赋值给 a 和 b 变量
❹ 更新节点 a 的邻居数量
❺ 更新节点 b 的邻居数量
让我们看看 deg_class 函数的实际应用:
julia> classes_df.deg_ml, classes_df.deg_web =
deg_class(gh, classes_df.ml_target)
([0, 0, 0, 3, 1, 0, 0, 0, 1, 2 ... 2, 0, 12, 1, 0, 1, 0, 0, 1, 0],
[1, 8, 1, 2, 1, 1, 6, 8, 7, 5 ... 213, 3, 46, 3, 20, 0, 2, 4, 2, 4])
我们在一次赋值操作中向 classes_df 数据框添加了两列。如前所述,在 Julia 中,我们可以从赋值的右侧解构迭代器(在我们的情况下是 deg_class 函数返回的元组)到其左侧传递的多个变量。
应用函数屏障技术
deg_class 函数的一个重要特性是它将很快。我们通过传递 classes_df.ml_target 向量到它中来使用你在第十一章中学到的函数屏障技术。所以,尽管在 deg_class 函数内部 classes_df 数据框不是类型稳定的,但 Julia 能够识别所有变量的类型,从而为其执行生成有效的代码。让我们使用 @time 和 @code_warntype 宏来检查 deg_class 函数是否高效:
julia> @time deg_class(gh, classes_df.ml_target);
0.007813 seconds (5 allocations: 589.250 KiB)
julia> @code_warntype deg_class(gh, classes_df.ml_target)
MethodInstance for deg_class(::SimpleGraph{Int64},
::SentinelArrays.ChainedVector{Int64, Vector{Int64}})
from deg_class(gh, class) in Main at REPL[106]:1
Arguments
#self#::Core.Const(deg_class)
gh::SimpleGraph{Int64}
class::SentinelArrays.ChainedVector{Int64, Vector{Int64}}
Locals
@_4::Union{Nothing, Tuple{Graphs.SimpleGraphs.SimpleEdge{Int64},
Tuple{Int64, Int64}}}
deg_web::Vector{Int64}
deg_ml::Vector{Int64}
edge::Graphs.SimpleGraphs.SimpleEdge{Int64}
b::Int64
a::Int64
Body::Tuple{Vector{Int64}, Vector{Int64}}
⋮
在 @time 宏产生的计时中,最重要的信息是代码执行了五次分配。分配的数量并不与函数中 for edge in edges(gh) 循环的迭代次数成比例(记住我们在图中几乎有 300,000 条边)。这是一个间接指示函数类型稳定的良好信号。
当我们检查 @code_warntype 宏的输出时,我们得到了这个确认。我已经截断了前面的输出,因为它相当长,但没有类型以红色(粗体)显示,并且它们都是具体的(回想一下第五章中关于具体类型的讨论)。
通过观察操作所需的时间,我们可以确定即使我们图中的所有开发者都通过边连接(一个完全图),我们也能在几秒钟内(在我的笔记本电脑上)处理它。请注意,这样一个图会相当大,因为它将有 710,626,150 条边(可以通过使用包含 37,700 个元素的集合的两元素子集数量的公式 37700(37700-1)/2 来计算)。
练习 12.1 使用 complete_graph(37700) 调用,在 37,700 个节点上创建一个完全图(我们在 gh 图中的节点数量)。但请注意:如果你的机器上少于 32 GB RAM,请使用较小的图大小,因为这个练习对内存密集型。接下来,使用 Base .summarysize 函数,检查这个图占用多少内存。最后,使用 @time 函数,检查 deg_class 函数在这个图上完成所需的时间,使用 classes_df.ml_target 向量作为开发者类型的向量。
解释分析结果
让我们检查添加了具有附加图特征的列后的 classes_df 数据框的摘要统计信息:
julia> describe(classes_df, :min, :max, :mean, :std)
6×5 DataFrame
Row │ variable min max mean std
│ Symbol Any Any Union... Union...
─────┼───────────────────────────────────────────────────────
1 │ id 1 37700 18850.5 10883.2
2 │ name 007arunwilson timqian
3 │ ml_target 0 1 0.258329 0.437722
4 │ deg 1 9458 15.3317 80.7881
5 │ deg_ml 0 1620 2.22981 13.935
6 │ deg_web 0 8194 13.1019 69.9712
观察到图中节点的平均度数略大于 15。我们可以通过使用 ne 和 nv 函数来交叉检查这个值。由于每条边都贡献了两个节点的度数,因此整个图中节点的平均度数应该是图中边数乘以 2 除以图中节点数:
julia> 2 * ne(gh) / nv(gh)
15.331724137931035
我们得到了与通过平均单个节点的度数得到的相同值,正如预期的那样。
还请注意,平均而言,开发者与网页开发者的链接比与机器学习开发者多。这并不奇怪,因为在图中,几乎 75% 的节点是网页开发者。从摘要中,我们还从最大值和标准差列的信息中了解到,图中节点的度数存在显著的变异性(我们将在分析数据时考虑这一观察结果)。
在继续前进之前,让我们检查对于每个节点,该节点的网页邻居数量和机器学习邻居数量的总和等于其总度数(因为一个节点的每个邻居要么是网页开发者,要么是机器学习开发者):
julia> classes_df.deg_ml + classes_df.deg_web == classes_df.deg
true
的确,这是正确的。
在 DataFrames.jl 中执行对象一致性检查
在开发更复杂的解决方案时执行数据一致性检查非常重要。例如,在 DataFrames.jl 中,当你对数据帧对象执行所选操作时,会运行 DataFrame 对象的一致性检查。以下是一个一致性检查被触发时的示例:
julia> df = DataFrame(a=1, b=11)
1×2 DataFrame
Row │ a b
│ Int64 Int64
─────┼──────────────
1 │ 1 11
julia> push!(df.a, 2)
2-element Vector{Int64}:
1
2
julia> df
Error showing value of type DataFrame:
ERROR: AssertionError: Data frame is corrupt: length of column :b (1)
does not match length of column 1 (2). The column vector has likely been
resized unintentionally (either directly or because it is shared with
another data frame).
此代码创建了一个包含一行两列的数据帧。接下来,我们使用 push!(df.a, 2) 操作仅向其一个列添加一个元素(回想第十章的内容,如果你想要向数据帧添加一行,你应该在整个数据帧上使用 push!)。如果我们尝试显示此数据帧,我们会得到一个错误,表明数据帧已损坏,因为数据帧中的所有列必须具有相同数量的元素。不要尝试处理损坏的数据帧。相反,定位导致此问题的代码部分并修复它。
12.3 使用 split-apply-combine 方法预测开发者的类型
在本节中,我们将检查我们是否可以通过学习一个节点的网页和机器学习邻居的数量来预测该节点的类型。直观上,我们预计机器学习开发者与其他机器学习开发者之间有更多的联系。同样,我们预计网页开发者会与网页开发者建立联系。你将学习如何在 DataFrames.jl 中应用 split-apply-combine 策略,使用 Plots.jl 创建复杂的图表,以及使用 GLM.jl 进行逻辑回归。
12.3.1 计算网页和机器学习开发者特征摘要统计
在本节中,我们将分别检查网页和机器学习开发者的 deg_ml 和 deg_web 变量的平均值。
使用索引的方法
首先,让我们通过使用我们在第九章中学到的索引语法来执行这个计算:
julia> using Statistics
julia> for type in [0, 1], col in ["deg_ml", "deg_web"]
println((type, col,
mean(classes_df[classes_df.ml_target .== type, col])))
end
(0, "deg_ml", 1.5985122134401488)
(0, "deg_web", 16.066878866993314)
(1, "deg_ml", 4.042304138001848)
(1, "deg_web", 4.589382893520895)
在循环中,我们遍历开发者类型(0 或 1)和列名(deg_ml 或 deg_web),并打印列的条件均值。我们看到网页开发者(编码为 0)的平均网页朋友比机器学习朋友多得多。对于机器学习开发者,网页和机器学习联系人的数量相当。
之前的代码虽然可行,但较为冗长,可读性不高,并且只将输出显示在屏幕上。必须有一种更优雅的方式来执行这些计算。确实如此:split-apply-combine 模式。
在 Hadley Wickham 的《数据分析的 Split-Apply-Combine 策略》一文中描述了这种模式(www.jstatsoft.org/article/view/v040i01),这种模式在许多支持数据帧操作的框架中得到了实现。如果你熟悉 Python 中的 pandas 或 R 中的 dplyr,这些概念将很熟悉。图 12.3 展示了这种方法。

图 12.3 在 split-apply-combine 策略中,我们根据 ml_target 列将源数据帧分割成两个组,对 deg_ml 列应用均值函数,并将结果合并回单个数据帧。
操作规范语法
在本节中,我通过一个示例解释了 DataFrames.jl 中的 split-apply-combine 模式。第十三章将涵盖这个主题的详细内容。
如果你想在 DataFrames.jl 中对数据按组进行聚合,你需要使用两个函数:
-
groupby—将数据帧分割;你已经在第十一章中学习了这个函数
-
combine—接受一个 GroupedDataFrame 对象并执行其聚合
我们首先按 ml_target 列对 classes_ml 数据帧进行分组,然后计算 deg_ml 和 deg_web 列的均值:
julia> gdf = groupby(classes_df, :ml_target)
GroupedDataFrame with 2 groups based on key: ml_target
First Group (27961 rows): ml_target = 0
Row │ id name ml_target deg deg_ml deg_web
│ Int64 String31 Int64 Int64 Int64 Int64
───────┼─────────────────────────────────────────────────────────
1 │ 1 Eiryyy 0 1 0 1
2 │ 2 shawflying 0 8 0 8
3 │ 4 SuhwanCha 0 5 3 2
: │ : : : : : :
27959 │ 37697 kris-ipeh 0 2 0 2
27960 │ 37698 qpautrat 0 4 0 4
27961 │ 37700 caseycavanagh 0 4 0 4
27955 rows omitted
⋮
Last Group (9739 rows): ml_target = 1
Row │ id name ml_target deg deg_ml deg_web
│ Int64 String31 Int64 Int64 Int64 Int64
──────┼──────────────────────────────────────────────────────────
1 │ 3 JpMCarrilho 1 1 0 1
2 │ 5 sunilangadi2 1 2 1 1
3 │ 33 city292 1 2 2 0
: │ : : : : : :
9737 │ 37694 chengzhongkai 1 4 1 3
9738 │ 37696 shawnwanderson 1 1 1 0
9739 │ 37699 Injabie3 1 3 1 2
9733 rows omitted
我们可以看到,正如预期的那样,在我们的 gdf 对象中有两个组。第一个对应于 ml_target 列的值为 0(网页开发者),第二个对应于 1(机器学习开发者)。当我们把 GroupedDataFrame 对象传递给 combine 函数时,它会对数据进行分组聚合操作。下面是语法,我将在下文中解释:
julia> combine(gdf,
:deg_ml => mean => :mean_deg_ml, ❶
:deg_web => mean => :mean_deg_web) ❶
2×3 DataFrame
Row │ ml_target mean_deg_ml mean_deg_web
│ Int64 Float64 Float64
─────┼──────────────────────────────────────
1 │ 0 1.59851 16.0669
2 │ 1 4.0423 4.58938
❶ 在 combine 函数中操作规范的指定由=>运算符连接的三个部分:源列名、应用于此列的函数以及目标列名。
combine 函数的结果是一个数据帧,其第一列是我们对 gdf 数据帧进行分组的变量(ml_target),接下来的列是我们执行聚合操作的结果。我们看到这些数字与我们之前使用 for 循环计算的结果相同。
在这个例子中,理解的关键元素是 combine 函数接受的单一操作规范语法。让我们专注于:deg_ml => mean => :mean_deg_ml 操作。这种语法告诉 combine 函数应该取:deg_ml 列,将其传递给 mean 函数,并将结果存储在:mean_deg_ml 列中。由于我们已经将 GroupedDataFrame 对象传递给 combine 函数,这些操作是按组应用的。图 12.4 进一步解释了这种语法。

图 12.4 在这个由 combine 函数接受的操作规范语法中,你传递计算的数据源,应用于源数据的操作,以及计算结果应存储的目标列名。
这种操作规范语法旨在设计得灵活且易于程序化使用(也就是说,操作规范的任何组件都可以作为变量传递)。在第十三章中,我们将讨论这种语法提供的更多选项。
DataFramesMeta.jl 领域特定语言
如果你是一个来自 R 的 dplyr 用户,你可能想知道你是否可以通过使用赋值语法达到相同的结果。这是使用 DataFramesMeta.jl 包可以实现的:
julia> using DataFramesMeta
julia> @combine(gdf,
:mean_deg_ml = mean(:deg_ml),
:mean_deg_web = mean(:deg_web))
2×3 DataFrame
Row │ ml_target mean_deg_ml mean_deg_web
│ Int64 Float64 Float64
─────┼──────────────────────────────────────
1 │ 0 1.59851 16.0669
2 │ 1 4.0423 4.58938
在这种语法中,你写@combine 而不是 combine,然后你可以使用赋值来指定操作。在操作中,我们使用符号来引用数据框的列名,因此我们在名称前使用冒号前缀。这种便利性是以表达式:mean_deg_ml = mean(:deg_ml)不是有效的 Julia 代码为代价的。在计算机科学术语中,我们称这样的代码为领域特定语言。因此,在 DataFrames.jl 生态系统内,当需要指定操作时(例如,聚合数据),提供了两个高级 API 来指定操作:
-
DataFrames.jl 提供的标准评估 API—这使用了图 12.4 中描述的=>语法。这种语法更冗长,但更易于程序化使用,并且是有效的 Julia 代码。
-
DataFramesMeta.jl 包提供的非标准评估 API—这使用了赋值运算符。这种语法更简短,但以依赖代码的非标准评估为代价。当与数据框进行交互式工作时,用户通常更倾向于使用它。
12.3.2 可视化节点网络和机器学习邻居数量之间的关系
现在我们已经调查了开发者的类型与其机器学习和网络邻居数量之间的聚合关系,我们可以更详细地通过视觉来分析它。以下是一个图表,如图 12.5 所示:
julia> using Plots
julia> scatter(classes_df.deg_ml, classes_df.deg_web;
color=[x == 1 ? "black" : "yellow"
for x in classes_df.ml_target],
xlabel="degree ml", ylabel="degree web", labels=false)

图 12.5 在这个节点机器学习和 Web 邻居数量的散点图中,开发者的类型由点颜色表示:黑色表示机器学习开发者,黄色(在印刷书中为灰色)表示 Web 开发者。
不幸的是,这个图表对于以下关键原因来说并不是很有信息量:
-
邻居数量的分布高度偏斜。(我们在计算数据摘要统计信息时已经看到了这一点。)
-
许多开发者拥有相同数量的 Web 和机器学习邻居,因此代表这些数据的点重叠。
我们将通过以下技术来解决这些问题:
-
绘制按开发者的 Web 和机器学习邻居数量组合聚合的数据。这样,对于这些值的每个组合,图表上都将有一个单独的点。
-
手动将图表的轴改为对数尺度。这样,我们将从视觉上解压缩图表的低度部分。
-
向显示的数据添加抖动(随机噪声)以进一步减少由图表上许多点引起的问题。
我们将从数据的聚合开始。我们希望有一个数据框,对于存储在 deg_ml 和 deg_web 列中的每个唯一值组合,它都能给我们提供关于 Web 开发者比例的信息:
julia> agg_df = combine(groupby(classes_df, [:deg_ml, :deg_web]),
:ml_target => (x -> 1 - mean(x)) => :web_mean)
2103×3 DataFrame
Row │ deg_ml deg_web web_mean
│ Int64 Int64 Float64
──────┼───────────────────────────
1 │ 0 1 0.755143
2 │ 0 8 0.952104
3 │ 3 2 0.148148
: │ : : :
2101 │ 41 14 0.0
2102 │ 101 18 0.0
2103 │ 2 213 1.0
2097 rows omitted
在此代码中,我们一次性对数据进行分组和聚合。由于我们想要根据两个变量条件性地聚合数据,我们将它们作为包含两个元素的向量[:deg_ml, :deg_web]传递给 groupby。在这种情况下,我们必须定义一个匿名函数 x -> 1 - mean(x)来执行聚合。原因是 mean(x)产生的是机器学习开发者的比例,而我们想要得到的是 Web 开发者的比例。
需要注意的是,我们必须将匿名函数用括号括起来,如下所示:
julia> :ml_target => (x -> 1 - mean(x)) => :web_mean
:ml_target => (var"#27#28"() => :web_mean)
如果我们省略括号,我们会得到以下结果:
julia> :ml_target => x -> 1 - mean(x) => :web_mean
:ml_target => var"#29#30"()
如您所见,由于 Julia 运算符优先级规则,目标列名被解释为匿名函数定义的一部分,这并非我们的意图。
在我们继续前进之前,看看 DataFramesMeta.jl 语法中相同的操作:
julia> @combine(groupby(classes_df, [:deg_ml, :deg_web]),
:web_mean = 1 - mean(:ml_target))
2103×3 DataFrame
Row │ deg_ml deg_web web_mean
│ Int64 Int64 Float64
──────┼───────────────────────────
1 │ 0 1 0.755143
2 │ 0 8 0.952104
3 │ 3 2 0.148148
: │ : : :
2101 │ 41 14 0.0
2102 │ 101 18 0.0
2103 │ 2 213 1.0
2097 rows omitted
在我看来,这比使用=>运算符的语法更容易阅读。现在我们已经有了聚合后的数据,让我们检查其摘要统计信息:
julia> describe(agg_df)
3×7 DataFrame
Row │ variable mean min median max nmissing eltype
│ Symbol Float64 Real Float64 Real Int64 DataType
─────┼────────────────────────────────────────────────────────────────
1 │ deg_ml 19.1992 0 9.0 1620 0 Int64
2 │ deg_web 98.0314 0 48.0 8194 0 Int64
3 │ web_mean 0.740227 0.0 1.0 1.0 0 Float64
对于绘图来说,重要的是每个轴都有一个等于 0 的最小值。我们不能应用标准的轴对数缩放(这可以在 Plots.jl 中使用 xscale=:log 和 yscale=:log 关键字参数完成)。因此,我们将使用 log1p 函数实现自定义的轴变换。此函数计算其参数加 1 的自然对数。因此,当传递 0 时,它返回 0:
julia> log1p(0)
0.0
在列表 12.6 中,我们对数据进行 log1p 转换,并对其进行抖动处理,同时为绘图轴定义自定义刻度以匹配所执行转换。在 gen_ticks 函数中,我们定义了 0 和 2 的连续幂次,直到接近要绘制的最大数值的舍入值;该函数返回一个包含刻度位置和刻度标签的元组。
列表 12.6 绘制聚合网络开发者数据的散点图
julia> function gen_ticks(maxv) ❶
max2 = round(Int, log2(maxv))
tick = [0; 2 .^ (0:max2)]
return (log1p.(tick), tick)
end
gen_ticks (generic function with 1 method)
julia> log1pjitter(x) = log1p(x) - 0.05 + rand() / 10 ❷
log1pjitter (generic function with 1 method)
julia> using Random
julia> Random.seed!(1234); ❸
julia> scatter(log1pjitter.(agg_df.deg_ml),
log1pjitter.(agg_df.deg_web);
zcolor=agg_df.web_mean, ❹
xlabel="degree ml", ylabel="degree web",
markersize=2, ❺
markerstrokewidth=0.5, ❻
markeralpha=0.8, ❼
legend=:topleft, labels="fraction web",
xticks=gen_ticks(maximum(classes_df.deg_ml)), ❽
yticks=gen_ticks(maximum(classes_df.deg_web))) ❽
❶ 生成自定义刻度的函数
❷ 对一个值应用 log1p 转换,并在 [-0.05,0.05] 范围内添加随机抖动
❸ 为随机数生成器设置种子以确保结果的重复性
❹ 为散点图上绘制的每个点指定颜色,对应于网络开发者的比例
❺ 设置每个点的大小
❻ 设置每个点的描边宽度
❼ 设置每个点的透明度
❽ 为绘图设置自定义刻度,确保它们跨越每个轴上的最大绘图值
图 12.6 显示了生成的结果图。我们可以看到几个关系。一般来说,随着节点网络邻居数量的增加,邻居是网络开发者的概率增加。同样,随着节点机器学习邻居数量的增加,邻居是机器学习开发者的概率也增加。
此外,我们注意到在 (0, 0) 坐标处没有点,因为我们的图中的每个节点都有一个正度数。最后,一般来说,一个节点的网络和机器学习邻居的数量之间似乎存在正相关关系。如果一个开发者有很多网络邻居,那么他们也有很多机器学习邻居的可能性增加。这种关系对于度数高的开发者尤其明显。
现在,让我们考虑与图 12.6 及其生成代码相关的几个技术方面。首先,观察抖动确实在这个图中很有用。如果我们省略了它,我们就会看到所有机器学习度数为 0 的点都在一条线上,它们很可能重叠。其次,gen_ticks 函数接受一个参数,即我们想在轴上绘制的最大值。然后,通过 round(Int, log2(maxv)),我们计算出需要将数字 2 提升到最接近这个数的幂。

图 12.6 在这个节点机器学习和网络邻居的散点图中,点越暗,给定数量的机器学习和网络邻居中网络开发者的比例越低。
注意,这在我们的图表上工作得很好。对于 x 轴,最大的机器学习学位是 1620,所以我们停在 2048(而不是 1024)。对于 y 轴,最大的网络学位是 8194,所以我们停在 8192(而不是 16384)。接下来,使用[0; 2 .^ (0:max2)],我们产生从 0 开始的刻度,然后是 2 的连续幂。最后,使用(log1p.(tick), tick),我们返回一个元组,包含使用 log1p 函数转换的刻度位置和刻度标签。
如果您想确保图表真正具有信息性,制作图表,如图 12.6 中的图表,可能会相当耗时。Plots.jl 包的大优点是它提供了许多自定义图表的选项。在 Plots.jl 文档(docs.juliaplots.org/stable/attributes/)中,您可以找到可用的绘图选项的详尽列表。
练习 12.2 检查如果从图中移除抖动,图 12.6 中的图表将如何看起来。
12.3.3 适配预测开发者类型的逻辑回归模型
在本节中,我们创建一个逻辑回归模型来检查 ml_target 变量与 deg_ml 和 deg_web 变量之间的关系。如果您对逻辑回归模型没有经验,您可以在mng.bz/G1ZV找到有关此主题的入门信息。
我们将使用 GLM.jl 包来估计模型参数,并且,像往常一样,我会向您展示一些在实践中有用的 GLM.jl 特性:
julia> using GLM
julia> glm(@formula(ml_target~log1p(deg_ml)+log1p(deg_web)),
classes_df, Binomial(), LogitLink())
StatsModels.TableRegressionModel{GeneralizedLinearModel{
GLM.GlmResp{Vector{Float64}, Binomial{Float64}, LogitLink},
GLM.DensePredChol{Float64, LinearAlgebra.Cholesky{Float64,
Matrix{Float64}}}}, Matrix{Float64}}
ml_target ~ 1 + :(log1p(deg_ml)) + :(log1p(deg_web))
Coefficients:
───────────────────────────────────────────────────────────────────────────
Coef. Std. Error z Pr(>|z|) Lower 95% Upper 95%
───────────────────────────────────────────────────────────────────────────
(Intercept) 0.30205 0.0288865 10.46 <1e-24 0.245433 0.358666
log1p(deg_ml) 1.80476 0.0224022 80.56 <1e-99 1.76085 1.84866
log1p(deg_web) -1.63877 0.0208776 -78.49 <1e-99 -1.67969 -1.59785
───────────────────────────────────────────────────────────────────────────
首先,让我们解释一下获得的结果。所有估计的系数都非常显著,这从输出中 Pr(>|z|)列的非常小的值中可以看出。正的 log1p(deg_ml)系数意味着随着节点机器学习邻居数量的增加,我们的模型预测该节点代表机器学习开发者的概率增加。同样,由于 log1p(deg_web)系数是负的,我们得出结论,随着节点网络邻居数量的增加,它代表机器学习节点的预测概率会降低。第十三章解释了如何使用此类模型对新数据进行预测。
现在,让我们转向模型的实现。为了适配逻辑回归模型,我们使用 glm 函数。在这种情况下,我们向它传递四个参数。第一个是模型公式。在这里,值得注意的是,我们可以方便地在公式中传递变量转换。在这种情况下,由于数据有显著的右偏斜,我们使用 log1p 函数转换的特征来适配模型。如果传递它们,@formula 宏会自动识别此类转换并创建适当的匿名函数:
julia> @formula(ml_target~log1p(deg_ml)+log1p(deg_web))
FormulaTerm
Response:
ml_target(unknown)
Predictors:
(deg_ml)->log1p(deg_ml)
(deg_web)->log1p(deg_web)
其次,为了拟合逻辑回归,我们需要通知 glm 我们的目标特征是二元的(遵循二项分布),通过传递 Binomial() 参数。最后,由于我们可以拟合多个可能的模型来处理二元数据,我们通过 LogitLink() 参数选择逻辑回归。例如,如果您想拟合 probit 模型 (www.econometrics-with-r.org/11-2-palr.html),请使用 ProbitLink() 代替。
练习 12.3 使用 probit 模型而不是 logit 模型来预测 ml_target 变量。使用 glm 函数的 ProbitLink() 参数。
在本章中,我展示了如何使用 GLM.jl 包来拟合预测模型。如果您想了解更多关于此包的功能,请参阅包手册 (juliastats.org/GLM.jl/stable/)。
12.4 检查数据框修改操作
DataFrames.jl 提供的用于修改数据框的关键选项如下:
-
使用赋值或广播赋值进行低级 API—使用此 API 的一个示例操作是 df.x .= 1,通过广播赋值将列 x 设置为 1。此 API 有时被称为 命令式,因为您明确指定了操作应如何执行。
-
使用数据框修改函数进行高级 API—此 API 有时被称为 声明式,因为您只需指定要执行的操作,并将如何最有效地执行它的决策委托给 DataFrames.jl。高级 API 在您想要执行 split-apply-combine 操作时非常有用。此高级 API 有两种形式:
-
使用标准评估—这是由 DataFrames.jl 提供的,并依赖于使用 (=>) 符号指定的操作。您已经了解了支持此 API 的 combine 函数。
-
使用非标准评估—这是由 DataFramesMeta.jl 包提供的,并允许您使用赋值 (=) 符号。我已经向您展示了支持此 API 的 @combine 宏。
-
本章重点解释了低级(命令式)API 的工作原理。第十三章将更详细地讨论高级(声明式)API。
DataFrames.jl 生态系统提供了这三个选项来处理数据框对象,因为不同的开发者对代码结构的偏好不同。在这本书中,我讨论了所有三种替代方案,以便您可以选择最适合您需求的方案。
12.4.1 执行低级 API 操作
您可以使用低级 API 以以下七种方式更新数据框的列:
-
在数据框中创建新列而不进行复制。
-
通过复制在数据框中创建新列。
-
通过广播在数据框中创建新列。
-
通过替换更新数据框中的现有列。
-
在数据框中就地更新现有列。
-
使用广播替换现有列来更新数据框中的现有列
-
使用广播就地更新数据框中的现有列
我们将在下一节中查看每个选项。
不复制创建数据框中的新列
要将向量 v 赋值给数据框 df 中的新列 a 而不进行复制,请编写以下之一:
df.a = v
df[!, "a"] = v
通过复制创建数据框中的新列
要将向量 v 复制到数据框 df 中的新列 a,请编写以下内容:
df[:, "a"] = v
使用广播创建数据框中的新列
要将标量或向量 s 广播到数据框 df 中的新列 a(分配此新列),请编写以下之一:
df.a .= s
df[!, "a"] .= s
df[:, "a"] .= s
通过替换现有列来更新数据框中的现有列
要将向量 v 就地赋值给数据框 df 中的现有列 a 而不进行复制(通过替换现有列),请编写以下之一:
df.a = v
df[!, "a"] = v
使用广播就地更新数据框中的现有列
要将向量 v 就地赋值给数据框 df 中的现有列 a(通过更新现有列中的数据),请编写以下内容:
df[:, "a"] = v
同样的规则可以用来更新数据框中仅选择的行 r(请注意,: 只是一种特殊的行选择器):
df[r, "a"] = v
使用广播替换现有列来更新数据框中的现有列
要使用广播将标量或向量 s 赋值给数据框 df 中的现有列 a 并替换它,请编写以下内容:
df[!, "a"] .= s
使用广播就地更新数据框中的现有列
要使用广播将标量或向量 s 就地赋值给数据框 df 中的现有列 a(通过更新现有列中的数据),请编写以下内容:
df[:, "a"] .= s
同样的规则可以用来更新数据框中仅选择的行 r(此类选择器的示例可以是 1:3 范围;请注意,: 只是一种特殊的行选择器):
df[r, "a"] .= s
在前面的选项中,我只包括了最常用的操作。您可以在 DataFrames.jl 文档中找到支持的低级 API 的完整操作列表(mng.bz/z58r)。请注意,列表很长,可能难以记忆。作为一个经验法则,您可以假设以下内容:
-
大多数情况下,我列出的操作将满足您的需求。
-
DataFrames.jl API 被设计为支持您可能需要的所有与复制或避免复制数据相关的行为操作。
因此,如果将来您需要执行这里未涵盖的特殊操作,您可以参考文档。
比较行选择器 ! 和 : 的行为
强调 ! 和 : 行选择器的工作方式之间的差异是有用的。假设列 a 存在于数据框 df 中。
让我们首先从数据框中读取数据开始。如果您使用 df[!, "a"] 从数据框中获取列 a,则此操作返回存储在此数据框中的列而不进行复制,而 df[:, "a"] 返回此列的副本。
另一种情况是,当你在赋值语句的左侧使用相同的语法时。在这种情况下,如果你写 df[:, "a"] = v 或 df[:, "a"] .= s,那么操作是就地进行的,即,右侧的数据被写入现有列。如果你写 df[!, "a"] = v,那么列 a 将被替换为向量 v 而不进行复制。最后,写 df[!, "a"] .= s 会分配一个新的向量并将列 a 替换为它。
练习 12.4 创建一个空数据帧。向其中添加一个名为 a 的列,存储值 1、2 和 3 而不进行复制。接下来,在数据帧中创建另一个名为 b 的列,它与列 a 是相同的向量(不进行复制)。检查列 a 和 b 存储的是相同的向量。在数据帧中存储两个相同的列是不安全的,所以在列 b 中存储其副本。现在检查列 a 和 b 存储的是相同的数据但它们是不同的对象。就地更新列 a 的前两个元素为 10。
12.4.2 使用 insertcols! 函数变异数据帧
在本节中,在我完成数据帧变异操作的回顾之前,你将了解 insertcols! 函数。这个函数用于向数据帧中添加新列。它的语法与 DataFrame 构造函数类似,其中你传递一个 column_name => value 对来向数据帧中添加列。
insertcols! 函数的特殊之处在于它允许你在数据帧中的任何位置添加一个列,并检查传递的列名是否已在目标数据帧中存在(以避免意外覆盖列)。你可以在 DataFrames.jl 文档中找到更多关于 insertcols! 函数的详细信息 (mng.bz/09Gm)。
我将展示一些关于它是如何工作的例子。从最基本模式开始,即在数据帧的末尾插入一个列:
julia> df = DataFrame(x=1:2)
3×1 DataFrame
Row │ x
│ Int64
─────┼───────
1 │ 1
2 │ 2
julia> insertcols!(df, :y => 4:5) ❶
3×2 DataFrame
Row │ x y
│ Int64 Int64
─────┼──────────────
1 │ 1 4
2 │ 2 5
julia> insertcols!(df, :y => 4:5) ❷
ERROR: ArgumentError: Column y is already present in the data frame
which is not allowed when `makeunique=true`
julia> insertcols!(df, :z => 1) ❸
3×3 DataFrame
Row │ x y z
│ Int64 Int64 Int64
─────┼─────────────────────
1 │ 1 4 1
2 │ 2 5 1
❶ 在数据帧的末尾插入一个新列
❷ 默认情况下,尝试插入重复的列名是一个错误。
❸ 标量会自动广播,就像在 DataFrame 构造函数中一样。
让我们通过在数据帧的某个位置插入一个新列来继续这个例子。为此,我们传递第二个参数,指定列应该添加的位置:
julia> insertcols!(df, 1, :a => 0) ❶
3×4 DataFrame
Row │ a x y z
│ Int64 Int64 Int64 Int64
─────┼────────────────────────────
1 │ 0 1 4 1
2 │ 0 2 5 1
julia> insertcols!(df, :x, :pre_x => 2) ❷
3×5 DataFrame
Row │ a pre_x x y z
│ Int64 Int64 Int64 Int64 Int64
─────┼───────────────────────────────────
1 │ 0 2 1 4 1
2 │ 0 2 2 5 1
julia> insertcols!(df, :x, :post_x => 3; after=true) ❸
3×6 DataFrame
Row │ a pre_x x post_x y z
│ Int64 Int64 Int64 Int64 Int64 Int64
─────┼───────────────────────────────────────────
1 │ 0 2 1 3 4 1
2 │ 0 2 2 3 5 1
❶ 在数据帧的第一个位置插入一个新列
❷ 在数据帧中 x 列之前插入一个新列
❸ 在数据帧中 x 列之后插入一个新列,这由 after=true 关键字参数表示
DataFrames.jl 中 => 运算符的使用
在 DataFrames.jl 中,=> 运算符在两个上下文中使用,不应混淆。
第一个上下文是 DataFrame 构造函数和 insertcols! 函数,其中语法形式为 column_name => value。这种形式不涉及任何数据操作。它用于将新数据放入数据帧中。这与 Julia 中字典的填充方式一致。
第二种上下文是 combine 函数支持的运算符语法(在第十三章中,您将了解到其他函数(如 select、select!、transform、transform!、subset 和 subset!)也支持相同的运算符语法)。运算符语法用于操作数据帧中已经存在的数据。其一般结构,如图 12.4 所示,是 source_column => operation_function => target_column_name。
摘要
-
图是一种由节点和连接节点的边组成的数据结构。您可以使用 Graphs.jl 包在 Julia 中处理图。如果您分析来自社交媒体的数据,如 Twitter 或 Facebook,其中节点代表用户,边代表它们之间的关系,您将需要与图一起工作。
-
SHA 模块提供了允许您计算所处理数据哈希值的函数。其中常用的一种算法是 SHA-256,通过 sha256 函数提供。您可以使用它来验证从网络下载的数据是否损坏。
-
ZipFile.jl 包提供了处理 ZIP 归档的工具。在数据科学项目中经常使用它,因为在许多情况下,数据源将以这种格式压缩。
-
您可以对数据帧对象执行广播操作,就像对矩阵执行一样。广播允许您方便地转换数据帧中存储的值。
-
DataFrames.jl 提供了一个基于索引语法的低级 API,用于修改数据帧的内容。在执行此类操作时,您可以使用赋值(=运算符)和广播赋值(.=运算符)语法。通常,形式为 df[:, column] = 的操作是在原地执行的,而形式为 df[!, column] = 或 df.column = 的操作则替换列。此 API 的设计是为了让开发者能够完全控制操作执行的方式。
-
Graphs.jl 包定义了 SimpleGraph 类型,它可以用来在 Julia 中表示图,以及多个允许您分析图属性(例如,列出节点的邻居)的函数。当您的分析中包含具有网络结构的数据时,此包非常有用。
-
在 SimpleGraph 类型中,图节点以从 1 开始的连续整数表示,图边以表示它们连接的节点的整数对表示。得益于这种表示方式,在 Julia 集合中使用基于 1 的索引(例如,数据帧)时,很容易保持节点元数据。
-
您可以使用 DataFrames.jl 中的高级 API 中的 groupby 和 combine 函数对数据帧对象执行拆分-应用-组合操作(更多相关函数将在第十三章中讨论)。combine 函数用于对每个组执行聚合操作。在汇总数据时,拆分-应用-组合操作通常非常有用。
-
combine 函数根据传递的操作规范语法执行数据的聚合。其一般结构如下:source_column => operation_function => target_column_name。例如,:a => mean => :a_mean 表示应将列 :a 的数据传递给均值函数,并将计算结果存储在 :a_mean 列中。操作规范语法在程序化使用时特别方便,因为它由有效的 Julia 代码组成。
-
你可以使用 DataFramesMeta.jl 包简化@combine 宏中聚合操作的规范。在这个语法中,你通过使用赋值形式来编写操作;例如,:a_mean = mean(:a) 形式与标准操作规范语法中的 :a => mean => :a_mean 等价。@combine 接受的语法更方便编写和阅读,但依赖于非标准评估。
-
Plots.jl 包提供了一系列丰富的选项,允许你灵活地塑造你创建的图表。可用的绘图属性列表可以在
docs.juliaplots.org/stable/attributes/找到。在实践中,创建一个突出显示你分析数据重要方面的定制图表通常是数据科学项目中成功的关键因素。 -
使用 GLM.jl 包,你可以拟合逻辑回归或概率回归。这些模型在你目标变量为二元时使用。
-
insertcols!函数可用于在数据框中就地添加列。此函数允许你在源数据框的任何位置添加列。
13 数据框的高级转换
本章涵盖
-
执行数据框和分组数据框的高级转换
-
链接转换操作以创建数据处理管道
-
排序、连接和重塑数据框
-
处理分类数据
-
评估分类模型
在第十二章中,你学习了如何通过使用组合函数的操作指定语法来执行数据框的基本转换。在本章中,你将学习更多使用此语法的复杂场景,以及更多接受此语法的函数:select、select!、transform、transform!、subset 和 subset!。使用这些函数,你可以方便地对列执行任何需要的操作。同时,这些函数针对速度进行了优化,并且可以选择使用多线程来执行计算。与第十二章一样,我还将向你展示如何使用 DataFramesMeta.jl 领域特定语言来指定这些转换。
在本章中,你还将学习如何通过使用连接操作来合并多个表。DataFrames.jl 对所有标准连接都有高效的实现:内连接、左连接和右连接、外连接、半连接和反连接,以及交叉连接。同样,我将向你展示如何使用 stack 和 unstack 函数重塑数据框。
高级数据转换能力与对连接和重塑数据框的支持相结合,使 DataFrames.jl 成为创建复杂数据分析管道的完整生态系统。通过能够将多个操作链接在一起,创建这些管道大大简化了。这可以通过本章中你将学习的@chain 宏来实现。
此外,你还将学习如何使用 CategoricalArrays.jl 包来处理分类数据(R 用户称之为因子)。在执行数据统计分析时,通常需要此功能。
如本书惯例,我将所有这些概念都基于真实数据集进行展示。这次,我们将使用斯坦福开放警务项目数据,该数据集可在 Open Data Commons Attribution License 下获得。在这个数据集中,每个观测值代表一次警察拦截,并包含关于事件多个特征的信息。我们的目标是了解哪些特征会影响在肯塔基州奥文斯伯勒警察拦截期间被捕的概率。在这个过程中,我们将专注于使用 DataFrames.jl 进行特征工程,以准备可用于创建预测模型的可用数据。
由于我们即将结束本书,因此预期本章的内容将比前几章更高级。本章涵盖了 DataFrames.jl 的许多功能,因此相对较长。因此,在描述中,我专注于解释新材料。
13.1 获取和预处理警察拦截数据集
在本节中,我们将执行分析前的准备工作。这些内容应该对您来说很熟悉,因为步骤是相同的(从网络上获取 ZIP 存档,检查其 SHA,从存档中提取 CSV 文件,并将内容加载到数据框中),正如第十二章所述。
不同之处在于,我将向您展示如何使用@chain 宏进行多个操作的管道。创建结合多个操作的管道是许多数据科学家,尤其是那些熟悉 R 中的%>%操作符的数据科学家所喜爱并经常使用的功能。在本节的末尾,我将向您展示如何使用 select!函数就地删除数据框中的列。
13.1.1 加载所有必需的包
在 Julia 中,一个常见的做法是在分析开始时加载我们将在项目中使用的所有包。我为尚未在本书中使用的包添加了注释:
julia> using CSV
julia> using CategoricalArrays ❶
julia> using DataFrames
julia> using DataFramesMeta
julia> using Dates
julia> using Distributions ❷
julia> import Downloads
julia> using FreqTables
julia> using GLM
julia> using Plots
julia> using Random
julia> using ROCAnalysis ❸
julia> using SHA
julia> using Statistics
julia> import ZipFile
❶ 包允许您处理分类数据(R 中的因子)
❷ 提供支持处理各种统计分布的包
❸ 提供用于评估分类模型功能的包
13.1.2 介绍@chain 宏
@chain 宏提供了类似于 R 中的管道(%>%)操作符的功能。我们在上一节中通过使用 DataFramesMeta.jl 包(该包最初由 Chain.jl 包提供并由 DataFramesMeta.jl 重新导出)导入了它。通过使用@chain 宏,您可以方便地执行数据的多步骤处理。
@chain 宏的工作基本规则
@chain 宏接受一个起始值和一个表达式块的范围(通常一行代码对应一个表达式)。这个宏的基本工作规则如下:
-
默认情况下,上一个表达式的结果用作当前表达式的第一个参数,并且当您指定当前表达式时,此参数被省略。
-
作为例外,如果当前表达式中至少有一个下划线(_),则第一个规则不适用。相反,每个下划线都被替换为上一个表达式的结果。
您可以在 Chain.jl 的 GitHub 页面(github.com/jkrumbiegel/Chain.jl)上找到@chain 宏遵循的完整规则列表。以下是一些使用示例的代码。从以下表达式开始:
julia> sqrt(sum(1:8))
6.0
这也可以用@chain 宏等价地写成如下:
julia> @chain 1:8 begin
sum
sqrt
end
6.0
在这个例子中,sum 和 sqrt 函数只接受一个参数,所以我们不需要使用下划线来指示上一个表达式结果的放置,我们甚至可以在函数后面省略括号。1:8 的起始值传递给 sum 函数,然后结果传递给 sqrt 函数。图 13.1 说明了这个过程。var1 和 var2 变量名仅用于说明目的,因为在实践中,@chain 宏生成的变量名保证不会与现有标识符冲突。

图 13.1 在这个@chain 宏中,每个操作都是一个接受一个参数的函数。箭头显示了如何使用临时变量重写宏调用的每个部分。
如果您想显式使用下划线,您可以按以下方式编写我们的示例代码:
julia> @chain 1:8 begin
sum(_)
sqrt(_)
end
6.0
现在让我们考虑一个更复杂的例子:
julia> string(3, string(1, 2))
"312"
这可以等价地写成以下形式:
julia> @chain 1 begin
string(2)
string(3, _)
end
"312"
在这种情况下,1 作为第一个参数发送给 string(1, 2)调用,因为 string(2)表达式没有下划线。这个操作的结果,即一个"12"字符串,被传递给 string(3, _)表达式。由于在这个表达式中下划线存在,它被转换为 string(3, "12")并产生"312"作为其结果。图 13.2 描述了这一过程。

图 13.2 评估每个操作都是接受两个参数的函数的@chain 宏
13.1.3 获取警察拦截数据集
我们现在准备下载、解压缩并将肯塔基州奥文斯伯勒的警察拦截数据加载到数据框中。在这个过程中,我们将使用@chain 宏,您在 13.1.2 节中了解过:
julia> url_zip = "https://stacks.stanford.edu/file/druid:yg821jf8611/" *
"yg821jf8611_ky_owensboro_2020_04_01.csv.zip"; ❶
julia> local_zip = "owensboro.zip"; ❷
julia> isfile(local_zip) || Downloads.download(url_zip, local_zip) ❸
true
julia> isfile(local_zip) ❹
true
julia> open(sha256, local_zip) == [0x14, 0x3b, 0x7d, 0x74, ❺
0xbc, 0x15, 0x74, 0xc5, ❺
0xf8, 0x42, 0xe0, 0x3f, ❺
0x8f, 0x08, 0x88, 0xd5, ❺
0xe2, 0xa8, 0x13, 0x24, ❺
0xfd, 0x4e, 0xab, 0xde, ❺
0x02, 0x89, 0xdd, 0x74, ❺
0x3c, 0xb3, 0x5d, 0x56] ❺
true
julia> archive = ZipFile.Reader(local_zip) ❻
ZipFile.Reader for IOStream(<file owensboro.zip>) containing 1 files:
uncompressedsize method mtime name
----------------------------------------------
1595853 Deflate 2020-04-01 07-58 ky_owensboro_2020_04_01.csv
julia> owensboro = @chain archive begin
only(_.files)
read
CSV.read(DataFrame; missingstring="NA")
end; ❼
julia> close(archive) ❽
❶ 我们想要获取的文件 URL
❷ 我们想要保存到本地的文件名
❸ 仅在文件不存在时获取文件;如果文件已存在,则打印 true
❹ 检查文件是否确实存在
❺ 通过检查其 SHA-256 确保文件内容正确
❻ 打开 ZIP 存档并检查其内容
❼ 从存档中提取 CSV 文件并将其加载到 DataFrame 中;使用@chain 宏将 NA 值视为缺失
❽ 在我们完成读取后关闭 ZIP 存档
在这个例子中,使用@chain 宏的表达式等同于以下代码行:
CSV.read(read(only(archive.files)), DataFrame; missingstring="NA");
在我看来,使用@chain 宏的版本更容易阅读和修改,如果需要的话。
我们已经创建了 owensboro 数据框。我抑制了其内容的打印,因为它很大。相反,让我们使用在第十二章中使用的 summary 和 describe 函数来获取其摘要信息:
julia> summary(owensboro)
"6921×18 DataFrame"
julia> describe(owensboro, :nunique, :nmissing, :eltype)
18×4 DataFrame
Row │ variable nunique nmissing eltype
│ Symbol Union... Int64 Type
─────┼─────────────────────────────────────────────────────────────────────
1 │ raw_row_number 0 Int64
2 │ date 726 0 Date
3 │ time 1352 0 Time
4 │ location 4481 0 String
5 │ lat 0 Float64
6 │ lng 9 Union{Missing, Float64}
7 │ sector 10 10 Union{Missing, String15}
8 │ subject_age 3 Union{Missing, Int64}
9 │ subject_race 4 18 Union{Missing, String31}
10 │ subject_sex 2 0 String7
11 │ officer_id_hash 87 0 String15
12 │ type 2 42 Union{Missing, String15}
13 │ violation 1979 0 String
14 │ arrest_made 0 Bool
15 │ citation_issued 0 Bool
16 │ outcome 2 0 String15
17 │ vehicle_registration_state 35 55 Union{Missing, String3}
18 │ raw_race 4 18 Union{Missing, String31}
我们的数据集有近 7000 个观测值和 18 列。我已经展示了关于文本列中唯一值的数量、记录的缺失值数量以及每列的元素类型的信息。在本章中,我们不会处理数据框的所有列。相反,我们将专注于以下特征:
-
日期—提供事件发生的时间信息。
-
类型—指示谁被警察拦截(车辆或行人)。这个列有 42 个缺失观测值。
-
是否逮捕—显示是否进行了逮捕。这将是我们目标列。
-
违规—提供记录的违规类型的文本描述。
在接下来的几节中,我们将更详细地查看这些列中的数据。首先,我们将使用下一列表中的 select!函数删除我们不需要的列。
列表 13.1:在 owensboro 数据框中就地删除不需要的列
julia> select!(owensboro, :date, :type, :arrest_made, :violation); ❶
julia> summary(owensboro)
"6921×4 DataFrame"
julia> describe(owensboro, :nunique, :nmissing, :eltype)
4×4 DataFrame
Row │ variable nunique nmissing eltype
│ Symbol Union... Int64 Type
─────┼──────────────────────────────────────────────────────────
1 │ date 726 0 Date
2 │ type 2 42 Union{Missing, String15}
3 │ arrest_made 0 Bool
4 │ violation 1979 0 String
❶ 就地更新数据框并仅保留其中列出的列
13.1.4 比较执行列操作的函数
在列表 13.1 中,我们看到 select!函数,这是 DataFrames.jl 提供的五个用于在数据框列上执行操作的函数之一。您已经在第十二章中看到另一个,即组合函数,当时您正在处理 GroupedDataFrame 对象。现在,让我们看看所有可用的函数:
-
combine—按照操作指定语法执行列转换,允许更改源中的行数(通常,将多行合并为一行,即聚合它们)
-
select—按照操作指定语法执行列转换,但结果将具有与源相同的行数和顺序
-
select!—与 select 相同,但就地更新源
-
transform—与 select 相同,但始终保留源中的所有列
-
transform!—与 transform 相同,但就地更新源
由于在列表 13.1 中我们使用了 select!函数,它通过仅保留我们传递的列名来就地更新 owensboro 数据框。
我列出的所有函数都允许在一个调用中传递多个操作指定,如列表 13.1 所示。此外,它们都与数据框和分组数据框一起工作。在后一种情况下,这些函数按组处理数据,正如您在第十二章中关于组合所学的。对于 select、select!、transform 和 transform!,如果它们应用于 GroupedDataFrame,则适用相同的规则:结果必须具有与源相同的行数和顺序。
以下列表比较了组合和转换在最小示例上的应用。图 13.3 和 13.4 也展示了这些函数,因为选项之间的差异很重要需要记住。

图 13.3:使用组合、转换和选择函数在数据框上执行:v => sum => :sum 操作的结果。转换函数是唯一一个始终保留源中所有列的函数。组合函数是唯一一个允许更改其结果行数与源数据框相比的函数。

图 13.4:使用组合、转换和选择函数在分组数据框上执行:v => sum => :sum 操作的结果。转换函数是唯一一个始终保留源中所有列的函数。组合函数是唯一一个允许更改其结果行数和顺序与源数据框相比的函数。
列表 13.2:比较组合、选择和转换操作
julia> df = DataFrame(id=[1, 2, 1, 2], v=1:4)
4×2 DataFrame
Row │ id v
│ Int64 Int64
─────┼──────────────
1 │ 1 1
2 │ 2 2
3 │ 1 3
4 │ 2 4
julia> combine(df, :v => sum => :sum) ❶
1×1 DataFrame
Row │ sum
│ Int64
─────┼───────
1 │ 10
julia> transform(df, :v => sum => :sum) ❷
4×3 DataFrame
Row │ id v sum
│ Int64 Int64 Int64
─────┼─────────────────────
1 │ 1 1 10
2 │ 2 2 10
3 │ 1 3 10
4 │ 2 4 10
julia> select(df, :v => sum => :sum) ❸
4×1 DataFrame
Row │ sum
│ Int64
─────┼───────
1 │ 10
2 │ 10
3 │ 10
4 │ 10
julia> gdf = groupby(df, :id)
GroupedDataFrame with 2 groups based on key: id
First Group (2 rows): id = 1
Row │ id v
│ Int64 Int64
─────┼──────────────
1 │ 1 1
2 │ 1 3
⋮
Last Group (2 rows): id = 2
Row │ id v
│ Int64 Int64
─────┼──────────────
1 │ 2 2
2 │ 2 4
julia> combine(gdf, :v => sum => :sum) ❹
2×2 DataFrame
Row │ id sum
│ Int64 Int64
─────┼──────────────
1 │ 1 4
2 │ 2 6
julia> transform(gdf, :v => sum => :sum) ❺
4×3 DataFrame
Row │ id v sum
│ Int64 Int64 Int64
─────┼─────────────────────
1 │ 1 1 4
2 │ 2 2 6
3 │ 1 3 4
4 │ 2 4 6
julia> select(gdf, :v => sum => :sum) ❻
4×2 DataFrame
Row │ id sum
│ Int64 Int64
─────┼──────────────
1 │ 1 4
2 │ 2 6
3 │ 1 4
4 │ 2 6
❶ df 的行合并成一个单一值 10,这是列 v 的总和。
❷ transform 保留源数据框的列。由于保留了源数据框的所有行,值 10 被伪广播到所有行(参见第十章中对伪广播的解释)。
❸ 与转换相同,但源数据框 df 的列不会被保留
❹ sum 是按组应用。每个组的行合并,因此每个组产生一个值;结果按组顺序存储。
❺ sum 是按组应用;transform 保留源数据框的列。由于源数据框的所有行都按原始顺序保留,值 4 和 6 被伪广播到对应于组的行。
❻ 与转换相同,但 v 列不会被保留。id 列被保留,因为这是我们按此列分组数据。
总结来说,关于列表 13.2 中 transform 和 select 的工作方式,最重要的是它们保证源数据框 df 的所有行都保留在结果中,并且它们的顺序没有改变。如果一个操作返回一个标量,它将被伪广播以填充所有所需的行(参见第十章中对伪广播的解释)。select 和 transform 的区别在于后者保留源数据框的所有列。
13.1.5 使用操作规范语法的简写形式
在列表 13.1 中需要注意的另一件事是,当我们使用 select! 函数时,我们只传递我们想要保留的列名。在第十二章中,你学习了操作规范语法使用以下通用模式:source_column => operation_function => target_column_name。
使用这种语法,你可能认为为了在列表 13.1 中保留 :date 列而不更改其名称,你需要编写 :date => identity => :date。但实际上不需要这样做,因为在操作规范语法的操作函数和目标列名部分是可选的。以下列表,使用列表 13.2 中定义的 df 数据框,展示了省略操作规范语法的第二和第三部分的结果。
列表 13.3 操作规范语法的简写版本
julia> select(df,
:v => identity => :v1, ❶
:v => identity, ❷
:v => :v2, ❸
:v) ❹
4×4 DataFrame
Row │ v1 v_identity v2 v
│ Int64 Int64 Int64 Int64
─────┼─────────────────────────────────
1 │ 1 1 1 1
2 │ 2 2 2 2
3 │ 3 3 3 3
4 │ 4 4 4 4
❶ 操作规范语法的完整版本
❷ 省略目标列名的版本;在这种情况下,目标列名是自动生成的,名为 v_identity。
❸ 省略操作函数的版本;在这种情况下,操作是列重命名。
❹ 省略操作函数和目标列名的版本;在这种情况下,操作是在目标数据框中以与源相同的名称存储列。
在这里,所有四个操作都产生相同的输出向量,但给列命名不同。注意,在操作规范语法的操作函数和目标列名部分都可以省略。省略操作函数等同于请求不对列进行任何转换,省略目标列名会导致自动生成目标列名。
在列表 13.1 中,我们从 owensboro 数据框中删除了不需要的列。现在让我们转向转换这些列。
13.2 调查违规项
我们首先调查的是:违规项。它是一个文本列,因此我们需要进行文本处理来理解其内容。在本节中,你将学习如何进行这些操作。分析存储文本数据的 DataFrame 列的内容是一个常见的操作,因此练习这些操作是值得的。
13.2.1 寻找最常见的违规行为
首先,快速查看:违规项的内容:
julia> owensboro.violation
6921-element SentinelArrays.ChainedVector{String, Vector{String}}:
"POSS CONT SUB 1ST DEG, 1ST OFF " ⋯ 18 bytes ⋯ " DRUG PARAPHERLIA -
BUY/POSSESS"
"FAILURE TO ILLUMITE HEAD LAMPS;" ⋯ 20 bytes ⋯ "LICENSE; NO REGISTRATION
PLATES"
"NO TAIL LAMPS; OPER MTR VEHICLE" ⋯ 136 bytes ⋯ "EC, 1ST OFF; VIOLATION
UNKNOWN"
"OPER MTR VEH U/INFLU ALC/DRUGS/" ⋯ 49 bytes ⋯ " LICENSE - 1ST OFF (AGG
CIRCUM)"
"OPERATING ON SUS OR REV OPER LICENSE"
⋮
"SPEEDING 10 MPH OVER LIMIT; FAILURE TO WEAR SEAT BELTS"
"SPEEDING 11 MPH OVER LIMIT; NO REGISTRATION RECEIPT"
"SPEEDING 17 MPH OVER LIMIT; FAI" ⋯ 127 bytes ⋯ "LATES; NO REGISTRATION
RECEIPT"
"SPEEDING 13 MPH OVER LIMIT"
"FAILURE OF NON-OWNER OPERATOR T" ⋯ 37 bytes ⋯ "THER STATE REGISTRATION
RECEIPT"
我们可以看到,违规类型使用标准文本进行编码,如果发生多个违规,它们的描述由分号 (😉 分隔。
为了结构化这些数据,我们希望从该列中提取最常见的违规行为的指标。在本分析中,我们还将所有超速违规行为聚合为一种类型,因为我们看到它们仅在超过速度限制的每小时英里数(mph)上有所不同。
为了实现这一目标,我们首先需要学习最常见的违规行为的类型,按照表 13.1 中的步骤进行。
表 13.1 寻找最常见的违规行为所采取的步骤
| # | 步骤描述 | 简化示例输出 |
|---|---|---|
| 输入数据。 | ["a1; b;c ","b; a2","a3"] | |
| 1 | 使用 ; 作为分隔符拆分每个观测,并去除前导和尾随空格字符。 | [["a1","b","c"],["b","a2"],["a3"]] |
| 2 | 将所有单个观测向量垂直连接成一个向量。 | ["a1","b","c","b","a2","a3"] |
| 3 | 将包含子串 "a" 的所有元素更改为字符串 "a"(对于实际数据,此字符串是 "SPEEDING")。 | ["a","b","c","b","a","a"] |
| 4 | 在向量中计算不同字符串的出现的次数,并按频率降序呈现。 | "a" │ 3"b" │ 2"c" │ 1 |
以下列表展示了不使用 DataFrames.jl,使用你在第一部分学到的函数实现这些步骤的示例。
列表 13.4 使用 Base Julia 寻找最常见的违规行为
julia> violation_list = [strip.(split(x, ";"))
for x in owensboro.violation] ❶
6921-element Vector{Vector{SubString{String}}}:
["POSS CONT SUB 1ST DEG, 1ST OFF (METHAMPHETAMINE)", "DRUG PARAPHERLIA -
BUY/POSSESS"]
⋮
["SPEEDING 13 MPH OVER LIMIT"]
["FAILURE OF NON-OWNER OPERATOR TO MAINTAIN REQ INS/SEC, 1ST OFF", "NO
OTHER STATE REGISTRATION RECEIPT"]
julia> violation_flat = reduce(vcat, violation_list) ❷
13555-element Vector{SubString{String}}:
"POSS CONT SUB 1ST DEG, 1ST OFF (METHAMPHETAMINE)"
"DRUG PARAPHERLIA - BUY/POSSESS"
⋮
"SPEEDING 13 MPH OVER LIMIT"
"FAILURE OF NON-OWNER OPERATOR TO MAINTAIN REQ INS/SEC, 1ST OFF"
"NO OTHER STATE REGISTRATION RECEIPT"
julia> violation_flat_clean = [contains(x, "SPEEDING") ?
"SPEEDING" : x for x in violation_flat] ❸
13555-element Vector{AbstractString}:
"POSS CONT SUB 1ST DEG, 1ST OFF (METHAMPHETAMINE)"
"DRUG PARAPHERLIA - BUY/POSSESS"
⋮
"SPEEDING"
"FAILURE OF NON-OWNER OPERATOR TO MAINTAIN REQ INS/SEC, 1ST OFF"
"NO OTHER STATE REGISTRATION RECEIPT"
julia> sort(freqtable(violation_flat_clean), rev=true) ❹
245-element Named Vector{Int64}
Dim1 │
────────────────────────────────────────────────┼─────
"FAILURE TO WEAR SEAT BELTS" │ 2689
"NO REGISTRATION PLATES" │ 1667
"FAILURE TO PRODUCE INSURANCE CARD" │ 1324
SPEEDING │ 1067
⋮ ⋮
"WANTON ENDANGERMENT-1ST DEGREE-POLICE OFFICER" │ 1
"WANTON ENDANGERMENT-2ND DEGREE-POLICE OFFICER" │ 1
❶ 将每个违规描述拆分成一个向量,并从中去除前导和尾随空格
❷ 使用 reduce 函数(参见第十章关于此函数的讨论)将所有单个向量垂直连接成一个向量
❸ 将包含文本中的 "SPEEDING" 的所有违规项替换为 "SPEEDING"
❹ 使用 rev=true 按降序对各种违规行为的计数进行排序
在结果中,我们看到最常见的违规行为如下:
-
未佩戴安全带
-
没有车牌
-
未出示保险卡
-
超速
之后,我们将调查这些违规类型如何影响被捕的概率。在列表 13.4 的分析中,我们使用了 contains 函数,该函数检查传递给它的第一个字符串是否包含传递给它的第二个字符串。
现在,让我们使用 DataFrames.jl 和管道重写此代码。以下列表展示了结果。我们再次遵循表 13.1 中描述的步骤。
列表 13.5 使用 DataFrames.jl 查找最频繁违规情况
julia> agg_violation = @chain owensboro begin
select(:violation =>
ByRow(x -> strip.(split(x, ";"))) =>
:v) ❶
flatten(:v) ❷
select(:v =>
ByRow(x -> contains(x, "SPEEDING") ? "SPEEDING" : x) =>
:v) ❸
groupby(:v)
combine(nrow => :count) ❹
sort(:count, rev=true) ❺
end
245×2 DataFrame
Row │ v count
│ Abstract... Int64
─────┼──────────────────────────────────────────
1 │ FAILURE TO WEAR SEAT BELTS 2689
2 │ NO REGISTRATION PLATES 1667
3 │ FAILURE TO PRODUCE INSURANCE CARD 1324
4 │ SPEEDING 1067
: │ : :
242 │ IMPROPER USE OF RED LIGHTS 1
243 │ DISPLAY OF ILLEGAL/ALTERED REGIS... 1
244 │ TRAFF IN CONT SUB, 2ND DEGREE, 1... 1
245 │ UUTHORIZED USE OF MOTOR VEHICLE-... 1
237 rows omitted
❶ 将每个违规描述拆分为一个向量,从中删除前导和尾随空格,并将结果存储在 :v 列中
❷ 将 :v 列的数据框展平,将其包含的向量转换为数据框的连续行
❸ 将包含 "SPEEDING" 的所有违规替换为 "SPEEDING"
❹ 通过按 :v 列对数据进行分组,获取每个组中行数,并将其存储在 :count 列中
❺ 使用 rev=true 按降序对违规进行排序
我选择这个例子来教你们更多关于 DataFrames.jl 的功能,这些功能我在接下来的章节中会讨论。
13.2.2 使用 ByRow 包装器向量化函数
在列表 13.5 中,在选择操作中,我们使用操作函数的 ByRow 包装器。例如,在 ByRow(x -> strip.(split(x, ";"))) 中,它是一个匿名函数的包装器。
ByRow 的目的是简单的:它将一个常规函数转换为一个向量化的函数,这样你就可以轻松地将其应用于集合中的每个元素。在 DataFrames.jl 的情况下,这些元素是数据框对象的行,因此得名。以下是一个 ByRow 如何工作的最小示例:
julia> sqrt(4) ❶
2.0
julia> sqrt([4, 9, 16]) ❷
ERROR: MethodError: no method matching sqrt(::Vector{Int64})
julia> ByRow(sqrt)([4, 9, 16]) ❸
3-element Vector{Float64}:
2.0
3.0
4.0
julia> f = ByRow(sqrt) ❹
(::ByRow{typeof(sqrt)}) (generic function with 2 methods)
julia> f([4, 9, 16]) ❺
3-element Vector{Float64}:
2.0
3.0
4.0
❶ sqrt 函数适用于标量。
❷ sqrt 函数不适用于向量。
❸ ByRow(sqrt) 是一个适用于向量的 sqrt 的向量化版本。
❹ 创建一个新的可调用对象 f,它是向量化的
❺ 你可以调用 f 而不需要在其后写一个点(.),以进行向量化操作。
ByRow(sqrt)([4, 9, 16]) 与广播 sqrt.([4, 9, 16]) 有相同的效果。那么,为什么还需要它呢?广播需要立即指定一个函数调用,而 ByRow(sqrt) 创建了一个新的可调用对象。因此,你可以将 ByRow 视为一个懒信号,你希望在传递参数后稍后将其函数广播。你可以在我们示例中定义 f 可调用对象时看到这一点。
13.2.3 展平数据框
列表 13.5 还介绍了 flatten 函数。其目的是将存储向量的列扩展为数据框的多个行。以下是一个最小示例:
julia> df = DataFrame(id=1:2, v=[[11, 12], [13, 14, 15]])
2×2 DataFrame
Row │ id v
│ Int64 Array...
─────┼─────────────────────
1 │ 1 [11, 12]
2 │ 2 [13, 14, 15]
julia> flatten(df, :v)
5×2 DataFrame
Row │ id v
│ Int64 Int64
─────┼──────────────
1 │ 1 11
2 │ 1 12
3 │ 2 13
4 │ 2 14
5 │ 2 15
在此代码中,我们将存储在 v 列中的向量扩展为数据框的多个行。存储在 id 列中的值会适当地重复。
13.2.4 使用便利语法获取数据框的行数
你可能对列表 13.5 中 combine 调用中的 nrow => :count 操作指定语法感到惊讶,因为它不符合你迄今为止所学的操作指定语法的任何规则。这种情况是一个例外。这是因为请求数据框中的行数,或在数据框的每个组中的行数,是一个常见的操作。此外,此操作的结果不需要传递源列(对于每个源列都会相同)。
因此,为了支持这种用更简单的语法,允许两种特殊形式:只需传递 nrow 或传递 nrow => target_column_name。在第一种情况下,使用默认的 :nrow 列名;在第二种情况下,用户传递我们想要存储数据的列的名称。以下是一个显示这种语法两种变体的示例:
julia> @chain DataFrame(id=[1, 1, 2, 2, 2]) begin
groupby(:id)
combine(nrow, nrow => :rows)
end
2×3 DataFrame
Row │ id nrow rows
│ Int64 Int64 Int64
─────┼─────────────────────
1 │ 1 2 2
2 │ 2 3 3
在这个例子中,源数据框有五行。在两行中,我们有一个值 1,在三行中,:id 列中有值 2。当传递 nrow 时,我们得到名为 :nrow 的列,包含这些数字。传递 nrow => :rows 产生相同的值,但列名为 :rows。
13.2.5 排序数据框
列表 13.5 使用 sort 函数对数据框的行进行排序。由于数据框可以包含许多列,我们传递一个列列表,指定应该在这些列上执行排序(当传递多个列时,排序是按字典顺序进行的)。以下是一些对数据框进行排序的示例:
julia> df = DataFrame(a=[2, 1, 2, 1, 2], b=5:-1:1)
5×2 DataFrame
Row │ a b
│ Int64 Int64
─────┼──────────────
1 │ 2 5
2 │ 1 4
3 │ 2 3
4 │ 1 2
5 │ 2 1
julia> sort(df, :b) ❶
5×2 DataFrame
Row │ a b
│ Int64 Int64
─────┼──────────────
1 │ 2 1
2 │ 1 2
3 │ 2 3
4 │ 1 4
5 │ 2 5
julia> sort(df, [:a, :b]) ❷
5×2 DataFrame
Row │ a b
│ Int64 Int64
─────┼──────────────
1 │ 1 2
2 │ 1 4
3 │ 2 1
4 │ 2 3
5 │ 2 5
❶ 数据框按列 :b 升序排序
❷ 数据框按列 :a 和 :b 的字典顺序升序排序
13.2.6 使用 DataFramesMeta.jl 的高级功能
列表 13.5 可以工作,但有点冗长,因为我们需要使用 ByRow 和匿名函数。对于简单的转换,在我看来,使用 ByRow 非常方便且易于阅读。以下是一个示例:
julia> df = DataFrame(x=[4, 9, 16])
3×1 DataFrame
Row │ x
│ Int64
─────┼───────
1 │ 4
2 │ 9
3 │ 16
julia> transform(df, :x => ByRow(sqrt))
3×2 DataFrame
Row │ x x_sqrt
│ Int64 Float64
─────┼────────────────
1 │ 4 2.0
2 │ 9 3.0
3 │ 16 4.0
然而,在列表 13.5 中,我们处理了非常长的表达式。在这些情况下,使用 DataFramesMeta.jl 的领域特定语言进行转换通常更容易。让我们用 DataFramesMeta.jl 重新编写列表 13.5 中的代码,以替换 select 函数调用:
@chain owensboro begin
@rselect(:v=strip.(split(:violation, ";"))) ❶
flatten(:v)
@rselect(:v=contains(:v, "SPEEDING") ? "SPEEDING" : :v) ❶
groupby(:v)
combine(nrow => :count)
sort(:count, rev=true)
end
❶ @rselect 宏定义在 DataFramesMeta.jl 包中。
代码现在更容易阅读了。由于 DataFramesMeta.jl 宏以 @. 为前缀,因此视觉上区分它们很容易。如第十二章所述,DataFramesMeta.jl 宏使用赋值语法之后的领域特定语言,而不是 DataFrames.jl 支持的操作规范语法。
在这个例子中,我们看到了 DataFramesMeta.jl 包中的 @rselect 宏。它是 select 函数的等效,但前面的 r 表示所有操作都应该按行执行,换句话说,用 ByRow 包装。此外,@select 宏在数据框的整个列上工作,就像 select 函数一样。让我们通过在数据框的列上计算平方根的相同操作来比较它们:
julia> df = DataFrame(x=[4, 9, 16])
3×1 DataFrame
Row │ x
│ Int64
─────┼───────
1 │ 4
2 │ 9
3 │ 16
julia> @select(df, :s = sqrt.(:x))
3×1 DataFrame
Row │ s
│ Float64
─────┼─────────
1 │ 2.0
2 │ 3.0
3 │ 4.0
julia> @rselect(df, :s = sqrt(:x))
3×1 DataFrame
Row │ s
│ Float64
─────┼─────────
1 │ 2.0
2 │ 3.0
3 │ 4.0
julia> select(df, :x => ByRow(sqrt) => :s)
3×1 DataFrame
Row │ s
│ Float64
─────┼─────────
1 │ 2.0
2 │ 3.0
3 │ 4.0
在这个例子中,所有代码行都给出了等效的结果。@rselect 和 @select 之间的区别在于后者中,我们需要在 sqrt 函数后添加一个点(.)。
表 13.2 列出了 DataFramesMeta.jl 提供的相关宏(你已经在第十二章中学习了 @combine),映射到 DataFrames.jl 函数。
表 13.2 将 DataFramesMeta.jl 宏映射到 DataFrames.jl 函数
| DataFramesMeta.jl | DataFrames.jl |
|---|---|
| @combine | combine |
| @select | select |
| @select! | select! |
| @transform | transform |
| @transform! | transform! |
| @rselect | select with automatic ByRow of operations |
| @rselect! | select! with automatic ByRow of operations |
| @rtransform | transform with automatic ByRow of operations |
| @rtransform! | transform! with automatic ByRow of operations |
这个列表看起来很长,但相对容易学习。你需要了解的基本函数是 combine、select 和 transform。在构造宏调用名称时,只需记住你可以在名称后追加 ! 以使操作就地执行,并在名称前放置 r 以使操作自动将所有操作用 ByRow 包装(从而向量化它们)。
13.3 准备预测所需的数据
在 13.2 节中,我们从警察拦截数据集中提取了最常见的违规类型。我们现在准备使用的数据,以预测逮捕的概率。
在本节中,你将学习如何执行数据框对象的复杂转换,以及如何将它们连接和重塑。所有这些操作在准备建模数据时通常都是必需的。
13.3.1 执行数据的初始转换
为了准备数据,以便以后可以用来拟合预测逮捕概率的模型,我们想要创建一个具有以下结构的数据框:
-
一个名为 arrest 的布尔列,指示是否进行了逮捕。
-
一个名为 day 的列,显示事件发生的星期几。
-
一个列类型,告诉我们谁被警察拦截。
-
四个布尔列,v1、v2、v3 和 v4,指示被警察拦截的四个最常见原因。在这种情况下,我使用简短的列名以节省输出中的水平空间。
下面的列表展示了创建所需数据框的转换语法。
列表 13.6 准备预测模型所需的数据
julia> owensboro2 = select(owensboro,
:arrest_made => :arrest, ❶
:date => ByRow(dayofweek) => :day, ❷
:type, ❸
[:violation => ❹
ByRow(x -> contains(x, agg_violation.v[i])) => ❹
"v$i" for i in 1:4]) ❹
6921×7 DataFrame
Row │ arrest day type v1 v2 v3 v4
│ Bool Int64 String15? Bool Bool Bool Bool
──────┼───────────────────────────────────────────────────────
1 │ true 4 pedestrian false false false false
2 │ false 7 vehicular false true false false
3 │ true 7 vehicular false false false false
4 │ true 2 vehicular false false false false
: │ : : : : : : :
6918 │ false 3 vehicular false false false true
6919 │ false 3 vehicular true true false true
6920 │ false 3 vehicular false false false true
6921 │ false 3 vehicular false false false false
6913 rows omitted
❶ 列重命名
❷ 使用 Dates 模块中的 dayofweek 函数提取星期几的数字
❸ 选择未进行转换的列
❹ 通过程序生成四个操作指定的向量
代码中最重要的一部分是最后一段,它展示了操作指定也可以作为向量传递。在这个例子中,操作向量如下:
julia> [:violation =>
ByRow(x -> contains(x, agg_violation.v[i])) =>
"v$i" for i in 1:4]
4-element Vector{Pair{Symbol, Pair{ByRow{Base.Fix2{typeof(contains),
var"#66#68"{Int64}}}, String}}}:
:violation => (ByRow{Base.Fix2{typeof(contains),
var"#66#68"{Int64}}}(Base.Fix2{typeof(contains),
var"#66#68"{Int64}}(contains, var"#66#68"{Int64}(1))) => "v1")
⋮
:violation => (ByRow{Base.Fix2{typeof(contains),
var"#66#68"{Int64}}}(Base.Fix2{typeof(contains),
var"#66#68"{Int64}}(contains, var"#66#68"{Int64}(4))) => "v4")
我们可以看到它在对 agg_violation 数据框中的四个最常见的违规行为进行查找。接下来,select 函数正确地处理了这个对象,将其作为一个包含四个操作指定请求的向量。
让我们再看看一个通过程序生成的转换示例。假设我们想要从 owensboro 数据框中的日期和 arrest_made 列中提取最小和最大元素。我们可以将此操作表达如下:
julia> combine(owensboro, [:date :arrest_made] .=> [minimum, maximum])
1×4 DataFrame
Row │ date_minimum date_maximum arrest_made_minimum arrest_made_maximum
│ Date Date Bool Bool
─────┼─────────────────────────────────────────────────────────────────────
1 │ 2015-09-01 2017-09-01 false true
这之所以有效,是因为我们广播了一个行矩阵和一个列向量来指定转换(这种广播的应用在第五章中讨论过):
julia> julia> [:date :arrest_made] .=> [minimum, maximum]
2×2 Matrix{Pair{Symbol}}:
:date=>minimum :arrest_made=>minimum
:date=>maximum :arrest_made=>maximum
此外,正如您在 combine 函数的输出中可以看到的,自动生成的列名很好地描述了执行的操作的结果。
练习 13.1 使用 DataFramesMeta.jl 的@rselect 宏重写列表 13.6 中的代码。
重命名数据框的列
操作指定语法允许您使用形式:old_column_name => :new_column_name 重命名数据框的列。
然而,列重命名非常常见。因此,DataFrames.jl 提供了两个专门用于此任务的函数:rename 和 rename!。与往常一样,这两个函数之间的区别在于 rename 创建一个新的数据框,而 rename!则就地更改传递的数据框。
基本语法与操作指定相同,即写作 rename(df, :old_column_name => :new_column_name)将列:old_column_name 重命名为:new_column_name,而不会更改数据框 df 中的其他列名。这两个函数支持几种其他列重命名样式;请参阅包文档(mng.bz/m2jW)以了解更多信息。
通常情况下,当您只想重命名数据框中的列时,请使用 rename。当您除了重命名列之外,还想对传递的数据框的列执行其他操作时,请使用 select。
13.3.2 处理分类数据
本节介绍了使用 CategoricalArrays.jl 包处理分类数据。
现在,在我们的 owensboro2 数据框中,我们有一个名为 day 的列,它存储的是天数,其中星期一是 1,星期二是 2,……,星期天是 7。在分析中使用天名而不是数字会更好。此外,我们希望天名能正确排序——也就是说,星期一应被视为第一天,通过星期天,应被视为最后一天。这意味着我们不能将天名作为字符串存储,因为 Julia 在排序我们的数据时会使用字母顺序(按字母顺序,星期五是第一个天名,星期三是最后的一个天名)。
其他生态系统通过因子或分类列提供了允许我们为预定义值集指定自定义顺序的功能。在 Julia 中也有提供,由 CategoricalArrays.jl 包提供。
分类值
分类变量可以是无序的(名义变量)或有序的分类(序数变量)。
例如,一种名义变量是一个汽车的颜色,它是从封闭列表中的几个颜色之一,如蓝色、黑色或绿色。
例如,美国传统的学术评级是一种序数变量,包括:A+、A、A-、B+、B、B-、C+、C、C-、D+、D、D-和 F,其中 A+为最高分,F 为最低分。
CategoricalArrays.jl 包为在 Julia 中处理分类值提供支持。您需要学习的这个包定义的四个最重要的函数如下:
-
categorical—创建分类值数组
-
levels——检查存储在分类数组中的值级别
-
levels!——在分类数组中设置级别及其顺序
-
isordered——检查一个分类数组是有序的(存储序数值)还是无序的(存储名义值)
当创建存储天数和天名之间映射的参考数据框时,我们将使用这些函数,如下所示。
列表 13.7 创建一周中天名的参考数据框
julia> weekdays = DataFrame(day=1:7,
dayname=categorical(dayname.(1:7);
ordered=true))
7×2 DataFrame
Row │ day dayname
│ Int64 Cat...
─────┼──────────────────
1 │ 1 Monday
2 │ 2 Tuesday
3 │ 3 Wednesday
4 │ 4 Thursday
5 │ 5 Friday
6 │ 6 Saturday
7 │ 7 Sunday
julia> isordered(weekdays.dayname)
true
julia> levels(weekdays.dayname)
7-element Vector{String}:
"Friday"
"Monday"
"Saturday"
"Sunday"
"Thursday"
"Tuesday"
"Wednesday"
julia> levels!(weekdays.dayname, weekdays.dayname)
7-element CategoricalArray{String,1,UInt32}:
"Monday"
"Tuesday"
"Wednesday"
"Thursday"
"Friday"
"Saturday"
"Sunday"
我们首先创建一个 weekdays 数据框,存储天数和天名之间的映射。我们通过使用 dayname 函数创建一个天名字段,该函数返回给定天数的文本名称。接下来,使用 categorical 函数将此列转换为分类列,并通过传递 ordered=true 关键字参数使其有序。接下来,我们检查该列是否有序并检查其级别。如你所见,级别默认按字母顺序排序。为了解决这个问题,我们使用 levels! 函数将天数的顺序设置为与天数顺序相同。该函数将分类数组作为第一个参数,将包含新级别排序的向量作为第二个参数。
你可能会问,使用分类数组来表示天名有什么好处。一个好处是,这样我们可以清楚地向用户表明该列包含一个封闭的允许值集合。然而,还存在另一个重要的好处。稍后,如果我们使用对值顺序敏感的函数(例如,排序),它们将尊重我们为分类向量设置的顺序。
13.3.3 数据框连接
在本节中,你将学习 DataFrames.jl 提供的函数,这些函数允许你将多个数据框连接在一起。
我们有一个将天数映射到分类天名的映射,但如何将它们放入我们的 owensboro2 数据框中呢?这可以通过将 owensboro2 数据框与 weekdays 数据框进行连接来实现。在这种情况下,我们需要执行一个左连接,我们希望在原地执行该操作——也就是说,我们希望将列添加到 owensboro2 数据框中。完成此操作的是 leftjoin! 函数,我们通过传递 on 关键字参数指定执行连接的列名(这是一个存储执行连接时应使用的键的列)。在操作的结果中,owensboro2 数据框包含我们在 13.6 列表中放入的所有列(即,arrest、day、type、v1、v2、v3 和 v4),并且还添加了从连接的 weekdays 数据框中添加的 dayname 列:
julia> leftjoin!(owensboro2, weekdays; on=:day)
6921×8 DataFrame
Row │ arrest day type v1 v2 v3 v4 dayname
│ Bool Int64 String15? Bool Bool Bool Bool Cat...?
──────┼──────────────────────────────────────────────────────────────────
1 │ true 4 pedestrian false false false false Thursday
2 │ false 7 vehicular false true false false Sunday
3 │ true 7 vehicular false false false false Sunday
4 │ true 2 vehicular false false false false Tuesday
: │ : : : : : : : :
6918 │ false 3 vehicular false false false true Wednesday
6919 │ false 3 vehicular true true false true Wednesday
6920 │ false 3 vehicular false false false true Wednesday
6921 │ false 3 vehicular false false false false Wednesday
6913 rows omitted
除了 leftjoin! 之外,DataFrames.jl 提供的最常用的连接函数会从传递的源数据框中创建一个新的数据框。这些函数如下所示:
-
innerjoin——包含所有通过数据框传递的键匹配的行
-
leftjoin——包含来自左侧数据框的所有行以及来自右侧数据框的匹配行
-
rightjoin——包含来自右侧数据框的所有行以及来自左侧数据框的匹配行
-
outerjoin—包括任何传递的数据帧中出现的键的行
你可以在 DataFrames.jl 手册(mng.bz/wyz2)以及相关函数的文档中找到有关可用连接函数及其选项的更多信息。
练习 13.2 编写一个选择操作创建 owensboro2 数据帧,该数据帧立即具有 dayname 列(无需执行连接)。
13.3.4 重塑数据帧
在本节中,你将学习如何使用 stack 和 unstack 函数来重塑数据帧。在继续之前,让我们查看下一个列表,以查看 owensoboro2 是否有正确的日期数字到日期名称的映射。
列表 13.8 以长格式映射日期数字到日期名称
julia> @chain owensboro2 begin
groupby([:day, :dayname]; sort=true)
combine(nrow)
end
7×3 DataFrame
Row │ day dayname nrow
│ Int64 Cat...? Int64
─────┼─────────────────────────
1 │ 1 Monday 913
2 │ 2 Tuesday 1040
3 │ 3 Wednesday 1197
4 │ 4 Thursday 1104
5 │ 5 Friday 1160
6 │ 6 Saturday 850
7 │ 7 Sunday 657
看起来情况确实如此。此外,我们可以看到,警察拦截次数最少的是星期日。检查映射的另一种方法是构建一个频率表:
julia> freqtable(owensboro2, :dayname, :day)
7×7 Named Matrix{Int64}
dayname \ day │ 1 2 3 4 5 6 7
──────────────┼─────────────────────────────────────────
"Monday" │ 913 0 0 0 0 0 0
"Tuesday" │ 0 1040 0 0 0 0 0
"Wednesday" │ 0 0 1197 0 0 0 0
"Thursday" │ 0 0 0 1104 0 0 0
"Friday" │ 0 0 0 0 1160 0 0
"Saturday" │ 0 0 0 0 0 850 0
"Sunday" │ 0 0 0 0 0 0 657
我们可以看到,我们只存储在主对角线上的值。我们生成的频率表是一个矩阵。由于在本章中我们正在使用数据帧,你可能想知道我们是否可以使用数据帧获得类似的结果。确实,这是可能的,使用 unstack 函数,如下所示。
列表 13.9 以宽格式映射日期数字到日期名称
julia> @chain owensboro2 begin
groupby([:day, :dayname]; sort=true)
combine(nrow)
unstack(:dayname, :day, :nrow; fill=0)
end
7×8 DataFrame
Row │ dayname 1 2 3 4 5 6 7
│ Cat...? Int64 Int64 Int64 Int64 Int64 Int64 Int64
─────┼────────────────────────────────────────────────────────────
1 │ Monday 913 0 0 0 0 0 0
2 │ Tuesday 0 1040 0 0 0 0 0
3 │ Wednesday 0 0 1197 0 0 0 0
4 │ Thursday 0 0 0 1104 0 0 0
5 │ Friday 0 0 0 0 1160 0 0
6 │ Saturday 0 0 0 0 0 850 0
7 │ Sunday 0 0 0 0 0 0 657
如你所见,我们得到了相同的结果,但这次是以数据帧的形式。unstack 函数接受三个位置参数:第一个是要用于指定行键(在这种情况下为 dayname)的数据,第二个是要用于指定列键(在这种情况下为 day)的列,第三个是要用于指定行键-列键组合的值的列(在这种情况下为 nrow)。我们另外传递了 fill=0 参数来指示具有缺失键组合的条目应取此值(默认情况下将是缺失)。
重塑数据帧
数据分析使用两种方法来表示数据:宽格式和长格式(www.statology.org/long-vs-wide-data/))。
对于以宽格式存储的数据,也称为 unstacked,假设每个实体代表一行数据,每个属性代表一列数据。列表 13.9 展示了这样一个映射的例子,其中日期名称被视为实体(每一行代表一个日期名称),日期数字被视为属性(由名为 1 到 7 的列表示)。
对于长格式数据,也称为 stacked,一行代表从实体-属性组合到分配给它的值的映射。列表 13.8 展示了这样一个映射的例子,其中实体名称存储在 dayname 列中,属性名称存储在 day 列中,与之相关的值存储在 nrow 列中。
在 DataFrames.jl 中,你可以通过使用 stack 函数将数据帧从宽格式转换为长格式,通过使用 unstack 函数从长格式转换为宽格式。
相关操作是数据框的转置,这由 permutedims 函数支持。
你可以在包手册中找到这些函数的使用示例(mng.bz/QnR1)。为了你的参考,以下图显示了 stack 和 unstack 函数之间的关系。

stack 函数将宽格式数据转换为长格式。[:a, :b]列选择器表示哪些列应转换为变量-值对。unstack 函数执行相反的操作。我们向它传递信息:哪些列应标识未展开数据框中的行(键,在我们的例子中),哪个列包含列名(变量,在我们的例子中),以及哪个列包含要放入行-列组合中的值(值,在我们的例子中)。
13.3.5 删除包含缺失值的数据框的行
数据准备建模的最后一步与缺失值有关。在列表 13.1 中,我们可以看到类型列有 42 个缺失元素。假设我们想在分析之前从 owensboro2 数据框中删除它们。这可以通过使用 dropmissing!函数就地完成:
julia> dropmissing!(owensboro2)
6879×8 DataFrame
Row │ arrest day type v1 v2 v3 v4 dayname
│ Bool Int64 String15 Bool Bool Bool Bool Cat... ❶
──────┼──────────────────────────────────────────────────────────────────
1 │ true 4 pedestrian false false false false Thursday
2 │ false 7 vehicular false true false false Sunday
3 │ true 7 vehicular false false false false Sunday
4 │ true 2 vehicular false false false false Tuesday
: │ : : : : : : : :
6876 │ false 3 vehicular false false false true Wednesday
6877 │ false 3 vehicular true true false true Wednesday
6878 │ false 3 vehicular false false false true Wednesday
6879 │ false 3 vehicular false false false false Wednesday
6871 rows omitted
❶ 所有元素类型在其末尾都没有?,这表示没有列包含缺失数据。
该操作就地更改数据框。如果我们想创建一个新的数据框,其中包含删除的缺失值,我们可以使用 dropmissing 函数。注意,现在数据框有 6,879 行,比原始的 6,921 行减少了 42 行,正如预期的那样。
此外,我们可以轻松地通过视觉确认没有列包含缺失数据。如果你查看列表 13.6,你可以看到列的类型元素是 String15?,而现在它是 String15。类型后面附加的问号表示该列允许缺失值。由于它现在已经消失了,这意味着在 dropmissing!操作之后,我们的数据框没有缺失值。
练习 13.3 为了练习你在本节中学到的操作,准备以下两个分析。首先,计算每天的名字列的逮捕概率。其次,再次计算逮捕概率,但这次是按 dayname 和 type 列计算的,并以宽表形式呈现结果,其中 dayname 级别是行,type 值是列。
在我们继续前进之前,让我们从 owensboro2 中删除 day 列,因为我们将在进一步的分析中不需要它:
julia> select!(owensboro2, Not(:day))
6879×7 DataFrame
Row │ arrest type v1 v2 v3 v4 dayname
│ Bool String15 Bool Bool Bool Bool Cat...
──────┼───────────────────────────────────────────────────────────
1 │ true pedestrian false false false false Thursday
2 │ false vehicular false true false false Sunday
: │ : : : : : : :
6878 │ false vehicular false false false true Wednesday
6879 │ false vehicular false false false false Wednesday
6875 rows omitted
13.4 构建逮捕概率的预测模型
在本节中,我们将构建一个逮捕概率的预测模型。与前面章节中介绍的方法相比,我们将使用更高级的流程。我们将随机将数据分成训练集和测试集,以验证我们的模型没有过拟合(www.ibm.com/cloud/learn/overfitting)。学习如何使用 DataFrames.jl 来完成这项工作是有用的,因为确保你的模型没有过拟合是大多数数据科学工作流程中的标准程序。
13.4.1 将数据分成训练集和测试集
我们首先添加指示变量 train,表示 owensboro2 数据框的某一行是否应该进入训练集或测试集。假设我们想要在这两个集合之间进行 70/30 的划分。
列表 13.10 随机生成数据指示列
julia> Random.seed!(1234); ❶
julia> owensboro2.train = rand(Bernoulli(0.7), nrow(owensboro2)); ❷
julia> mean(owensboro2.train)
0.702427678441634
❶ 设置随机数生成器的种子,以确保实验的可重复性
❷ 从成功概率为 0.7 的伯努利分布中抽取随机数
我们可以看到,在大约 70%的情况下,train 列的值为 true,表示该行应该进入训练数据集。当值为 false 时,该行进入测试数据集。在生成 train 列时,我们从伯努利分布(mng.bz/Xaql)中以 0.7 的成功概率抽取 true 和 false 值。伯努利类型在 Distributions.jl 包中定义。这个包提供了你可能在代码中想要使用的广泛分布——包括单变量(如 Beta 或 Binomial)和多变量(如 Multinomial 或 Dirichlet)。有关详细信息,请参阅包文档(juliastats.org/Distributions.jl/stable/)。
该包的设计高度可组合。例如,如果你想从一个分布中抽取一个随机样本,你可以将其作为第一个参数传递给标准的 rand 函数。在列表 13.10 中,我们编写了 rand(Bernoulli(0.7), nrow(owensboro2))来从伯努利分布中抽取与 owensboro2 数据框行数相同的次数。
在下一个列表中,我们创建了包含 owensboro2 数据框具有 true 和 false 值的行的 train 和测试数据框。
列表 13.11 创建训练和测试数据框
julia> train = subset(owensboro2, :train)
4832×8 DataFrame
Row │ arrest type v1 v2 v3 v4 dayname train
│ Bool String15 Bool Bool Bool Bool Cat... Bool
──────┼──────────────────────────────────────────────────────────────────
1 │ true pedestrian false false false false Thursday true
2 │ false vehicular false true false false Sunday true
: │ : : : : : : : :
4831 │ false vehicular true true false true Wednesday true
4832 │ false vehicular false false false true Wednesday true
4828 rows omitted
julia> test = subset(owensboro2, :train => ByRow(!))
2047×8 DataFrame
Row │ arrest type v1 v2 v3 v4 dayname train
│ Bool String15 Bool Bool Bool Bool Cat... Bool
──────┼──────────────────────────────────────────────────────────────────
1 │ true vehicular false false false false Tuesday false
2 │ true vehicular false false false false Sunday false
: │ : : : : : : : :
2046 │ false vehicular false false true false Friday false
2047 │ false vehicular false false false false Wednesday false
2043 rows omitted
注意,train 数据框中的 train 列只包含真实值,而在测试数据框中,它只包含假值。为了执行行子集操作,这次我们使用 subset 函数,该函数根据传递的条件创建一个新的数据框。像往常一样,它的 subset! 等价函数在原地操作。subset 函数接受操作指定语法,就像 combine 或 select 一样。唯一的区别是它需要不指定目标列名的形式,因为我们不是创建任何列,而是在子集行。当然,操作的结果必须是布尔值,因为我们将其用作子集行的条件。
对于接受操作指定语法的其他函数,DataFramesMeta.jl 提供了 @subset、@subset!、@rsubset 和 @rsubset! 便利宏。回想一下,r 前缀意味着传递的操作应该按行执行,而 ! 后缀意味着我们想要原地更新数据框而不是创建一个新的数据框。让我们使用 @rsubset 宏再次创建测试数据框作为练习:
julia> @rsubset(owensboro2, !(:train))
2047×8 DataFrame
Row │ arrest type v1 v2 v3 v4 dayname train
│ Bool String15 Bool Bool Bool Bool Cat... Bool
──────┼──────────────────────────────────────────────────────────────────
1 │ true vehicular false false false false Tuesday false
2 │ true vehicular false false false false Sunday false
: │ : : : : : : : :
2046 │ false vehicular false false true false Friday false
2047 │ false vehicular false false false false Wednesday false
2043 rows omitted
练习 13.4 通过(a)数据框索引语法和(b)groupby 函数创建训练和测试数据框。
13.4.2 拟合逻辑回归模型
现在,我们已经准备好构建我们的模型了。我们将使用在前面章节中介绍过的 GLM.jl 包。我们将通过使用训练数据集来构建模型,然后比较其在训练和测试数据集之间的预测能力:
julia> model = glm(@formula(arrest~dayname+type+v1+v2+v3+v4),
train, Binomial(), LogitLink())
StatsModels.TableRegressionModel{GeneralizedLinearModel{
GLM.GlmResp{Vector{Float64}, Binomial{Float64}, LogitLink},
GLM.DensePredChol{Float64, LinearAlgebra.Cholesky{Float64,
Matrix{Float64}}}}, Matrix{Float64}}
arrest ~ 1 + dayname + type + v1 + v2 + v3 + v4
Coefficients:
───────────────────────────────────────────────────────────────────────────
Coef. Std. Error z Pr(>|z|) Lower 95% Upper 95%
───────────────────────────────────────────────────────────────────────────
(Intercept) 0.28762 0.215229 1.34 0.1814 -0.134216 0.70946
dayname: Tuesday 0.13223 0.216134 0.61 0.5407 -0.291381 0.55585
dayname: Wednesday 0.07929 0.21675 0.37 0.7145 -0.34553 0.50411
dayname: Thursday -0.03443 0.218522 -0.16 0.8748 -0.462734 0.39385
dayname: Friday 0.19434 0.202768 0.96 0.3378 -0.203075 0.59176
dayname: Saturday 0.59492 0.204298 2.91 0.0036 0.194504 0.99533
dayname: Sunday 1.02347 0.205539 4.98 <1e-06 0.620622 1.42632
type: vehicular -1.34187 0.16969 -7.91 <1e-14 -1.67445 -1.00928
v1 -2.40105 0.147432 -16.29 <1e-58 -2.69001 -2.11208
v2 -2.46956 0.18695 -13.21 <1e-39 -2.83598 -2.10315
v3 -0.55070 0.149679 -3.68 0.0002 -0.844072 -0.25734
v4 -2.96624 0.289665 -10.24 <1e-23 -3.53397 -2.3985
───────────────────────────────────────────────────────────────────────────
从模型中,我们了解到逮捕的概率在星期日最高。如果拦截类型是车辆,逮捕的概率会下降。此外,对于违规类型 v1、v2、v3 和 v4,逮捕的概率也会下降。这是可以预料的,因为这些违规行为包括未系安全带、未注册车牌、未出示保险证明和超速。似乎没有哪一项违规行为足够严重,通常会导致逮捕。
我想引起您对我们所获得输出的一项特性的注意。对于 dayname 变量,星期一被选为参考水平(因此不在摘要中显示),其余水平被正确排序。这是可能的,因为 dayname 列是分类的,所以 glm 函数尊重该变量中级别的顺序。
现在,让我们评估我们模型的预测质量。我们首先使用 predict 函数将它的预测存储在 train 和 test 数据框中,分别:
julia> train.predict = predict(model) ❶
4832-element Vector{Float64}:
0.5629604404770923
0.07583480306410262
⋮
0.00014894383671078636
0.019055034096545412
julia> test.predict = predict(model, test) ❷
2047-element Vector{Union{Missing, Float64}}:
0.2845489586270871
0.4923077257785381
⋮
0.19613838972815223
0.27389501945271594
❶ 默认情况下,predict 函数返回用于构建模型的那个数据集的预测结果。
❷ 如果你将数据集作为 predict 函数的第二个参数传递,你将得到对新数据的预测。
13.4.3 评估模型预测质量
让我们比较由逮捕列的值定义的组别中模型预测的直方图。我们预计直方图之间不会重叠太多,因为这表明模型能够相对较好地将逮捕和非逮捕区分开来:
julia> test_groups = groupby(test, :arrest);
julia> histogram(test_groups[(false,)].predict;
bins=10, normalize=:probability,
fillstyle= :/, label="false")
julia> histogram!(test_groups[(true,)].predict;
bins=10, normalize=:probability,
fillalpha=0.5, label="true")
我们按逮捕列对测试数据框进行分组。然后,为了生成直方图,我们从这个分组数据框中提取代表逮捕值为假的第一个组,然后是逮捕值为真的第二个组。如果你想要刷新你对分组 DataFrame 索引的理解,你可以在第十一章中找到所有需要的解释。
对于第一组,我们使用直方图函数。对于第二组,我们使用直方图!函数,它将第二个直方图添加到同一图表中。两个直方图都使用 10 个区间进行绘制,所呈现的值是这些区间的概率。通过 fillalpha=0.5 关键字参数,我们使第二个直方图变得透明。fillstyle=:/关键字参数为第一个直方图添加线条,以便在黑白打印时易于区分。图 13.5 显示了我们的操作结果。
图 13.5 确认了如果逮捕为假,预测值较低,而如果为真,预测值较高。

图 13.5 展示了测试数据集中逮捕列值为真和假的预测值的直方图。我们可以看到,模型相对较好地将观察结果分开。
现在考虑以下实验。假设你设置了一个特定的阈值——让我们以 0.15 为例——并决定将所有预测值小于或等于 0.15 的观察结果分类为假,大于 0.15 的为真。如果我们进行这样的分类,我们有时会做出正确的决定(预测观察到的真为真或观察到的假为假),有时会犯错误(预测观察到的真为假或观察到的假为真)。在下面的代码中,我们制作了一个表格来总结这些结果:
julia> @chain test begin
@rselect(:predicted=:predict > 0.15, :observed=:arrest)
proptable(:predicted, :observed; margins=2)
end
2×2 Named Matrix{Float64}
predicted \ observed │ false true
─────────────────────┼───────────────────
false │ 0.811154 0.169492
true │ 0.188846 0.830508
这个表格通常被称为混淆矩阵(mng.bz/yaZ7)。我们通过使用 FreqTables.jl 包中的 proptable 函数来创建它,并且由于我们传递了 margins=2 关键字参数,列中的值加起来等于 1。
例如,我们混淆矩阵的第二行第一列中的数值 0.188846 告诉我们,对于所选的阈值,大约有 18.88%的概率我们会错误地将观察到的假分类为真。让我们称这个为假警报概率(pfa)。同样,我们混淆矩阵的第一行第二列中的数值 0.169492 告诉我们,对于所选的阈值,大约有 16.95%的概率我们会错误地将观察到的真分类为假。让我们称这个为漏报概率(pmiss)。图 13.6 展示了这些关系。

这个混淆矩阵的列元素加起来等于 1。模型犯两种错误,其概率为 pmiss 和 pfa。
pfa 和 pmiss 越低,我们模型的品质就越好。然而,我们已经针对一个固定的分类阈值进行了计算,例如示例中的 0.15,这个值是任意选择的。解决这个问题的自然方法是为所有可能的截止阈值绘制 pfa 和 pmiss 之间的关系图。ROCAnalysis.jl 包提供了这个功能。
现在,我们将创建一个图表,展示 x 轴上的 pfa 与 y 轴上的 pmiss 之间的关系。此外,我们还将计算一个随机选择的具有真实标签的观测值比随机选择的具有错误标签的观测值预测概率。我们希望这个概率接近 0%,这对于一个好的分类器来说是很重要的。请注意,对于随机模型(不做任何有用的预测),这个概率等于 50%。我们可以称这个值为“pfa-pmiss 曲线下的面积”(AUC)。根据我们的定义,AUC 越低,模型越好。
使用 ROCAnalysis.jl 包,我们将为模型的测试和训练数据集预测绘制 pfa-pmiss 曲线,并在下一个列表中计算 AUC 指标。
列表 13.12 绘制用于评估模型的 pfa-pmiss 曲线
julia> test_roc = roc(test; score=:predict, target=:arrest)
ROC curve with 62 points, of which 14 on the convex hull
julia> plot(test_roc.pfa, test_roc.pmiss;
color="black", lw=3,
label="test (AUC=$(round(100*auc(test_roc), digits=2))%)",
xlabel="pfa", ylabel="pmiss")
julia> train_roc = roc(train, score=:predict, target=:arrest)
ROC curve with 73 points, of which 16 on the convex hull
julia> plot!(train_roc.pfa, train_roc.pmiss;
color="gold", lw=3,
label="train (AUC=$(round(100*auc(train_roc), digits=2))%)")
我们首先使用 ROCAnalysis.jl 包中的 roc 函数。它接受一个数据框作为参数,以及 score 和 target 关键字参数,其中我们传递存储预测和真实标签的列名。生成的对象有两个我们使用的属性:pfa 和 pmiss,它们存储了不同截止阈值下的 pfa 和 pmiss 指标值,因此可以用来生成图表。最后,使用 auc 函数,我们计算 pfa-pmiss 曲线下的面积。这些操作是在测试和训练数据框上进行的,以检查获得的结果是否相似。
图 13.7 显示了列表 13.12 生成的结果。我们可以看到测试和训练模型的 pfa-pmiss 曲线几乎完全相同,因此我们可以得出结论,它没有过度拟合。AUC 低于 15%,这表明模型具有相对较好的预测能力。
我故意保持模型简单,以避免过度复杂化本章中讨论的内容。如果我们想在实践中使用这个模型,我会建议添加更多特征并允许它们之间的交互。此外,在 Julia 生态系统内,除了广义线性模型外,还有许多其他预测模型可用。你可以在 MLJ.jl 包的文档中找到一个示例列表(mng.bz/M09E)。

图 13.7 测试和训练数据集上模型预测的 pfa-pmiss 曲线几乎完全相同,这表明我们没有过度拟合模型的问题。
ROCAnalysis.jl 包采用了一种分析 pfa-pmiss 曲线下面积的方法。在其他一些来源中(例如,mng.bz/aPmx),pmiss 度量被 1-pmiss 度量所取代。然后,在曲线的 y 轴上,我们绘制当目标为真时做出正确决策的概率。在这种情况下,曲线下的面积是最大化的(不是最小化,如我们案例中所示),可以计算为 1 减去 auc 函数的返回值。
使用 Julia 进行机器学习
在本章中,我们手动创建并评估了一个简单的预测模型。如果您想创建更复杂的机器学习工作流程,我建议学习 MLJ 框架 (github.com/alan-turing-institute/MLJ.jl)。
Julia 中的机器学习(MLJ)是一个工具箱,提供选择、调整、评估、组合和比较 160 多个机器学习模型的通用接口和元算法。
此外,附录 C 列出了您可能觉得有用的各种包,如果您想超越简单的数据分析,开始在 Julia 中做高级数据科学项目。
13.5 查看 DataFrames.jl 提供的功能
在本章中,您看到了 DataFrames.jl 提供的许多功能。总之,以下是第二部分讨论的函数概述,以便您有一个简要的参考:
-
构建数据框—DataFrame 和 copy
-
提供摘要信息—describe, summary, ncol, nrow
-
与列名一起工作—名称、重命名、重命名!
-
向数据框添加行—append!, push!, vcat
-
迭代—eachrow, eachcol
-
索引—getindex, setindex!
-
添加列—insertcols!
-
转换列—combine, select, select!, transform, transform!, flatten
-
分组—groupby
-
子集行—subset, subset!, dropmissing, dropmissing!
-
重塑—stack, unstack, permutedims
-
排序—sort, sort!
-
连接—innerjoin, leftjoin, leftjoin!, rightjoin, outerjoin
这个列表相当长。我只列出了第二部分中介绍的功能。要查看所有可用功能的完整列表,请查看 DataFrames.jl 文档 (mng.bz/gR7Z)。此外,对于我们已经讨论过的函数,我省略了它们提供的一些功能,而只关注最常用的功能。在文档中,您将找到 DataFrames.jl 提供的每个函数的完整描述,以及使用示例。
combine、select、select!、transform、transform!、subset 和 subset!支持的操作规范语法比本书的这一部分所涵盖的功能更多。我仅选择了最常用的模式。您可以在软件包文档中找到所有可用功能的完整解释(mng.bz/epow)。此外,在我的博客文章“DataFrames.jl Minilanguage Explained”(mng.bz/p6pE)中,我还准备了对操作规范语法的回顾。
许多 DataFrames.jl 的用户喜欢使用 DataFramesMeta.jl 领域特定语言,尤其是在与@chain 宏结合使用时。这个软件包也具有许多我无法在本书中涵盖的功能;您可以在其文档中找到更多信息(mng.bz/O6Z2)。在我的博客文章“Welcome to DataFramesMeta.jl”(mng.bz/YK7e)中,我还为这个软件包提供的最常用宏创建了一个简短的指南。
总结来说,您可以将 DataFrames.jl 视为一个成熟的软件包。它已经开发了 10 年,它提供的众多功能反映了在这段时间内其用户的各种需求。
此外,该软件包已经超过了 1.0 版本发布,这意味着它保证在 2.0 版本发布之前(预计不会很快)不会引入破坏性更改。在我的经验中,这个承诺对于考虑将其用于生产环境的用户来说最为相关。
与 DataFrames.jl 的生产使用相关的一个重要设计方面是,它被设计为要么产生正确的结果,要么引发异常。您可能已经注意到,在这本书的任何地方都没有看到任何警告信息打印出来。这是故意的。在生产环境中运行代码时,警告通常会被无声地忽略。在 DataFrames.jl 中,在许多函数中,我们提供了关键字参数,允许您将错误转换为可接受的行为。让我们来看一个例子。
在 R 中,当您创建具有重复列名的数据框时,它会被静默接受,并且会进行列重命名(这是 R 代码):
> data.frame(a=1,a=2)
a a.1
1 1 2
在 DataFrames.jl 中,默认情况下,我们不允许重复的列名,因为大多数情况下此代码是错误的,并且应该被修复:
julia> DataFrame(:a=>1, :a=>2)
ERROR: ArgumentError: Duplicate variable names: :a. Pass makeunique=true
to make them unique using a suffix automatically.
然而,如果您希望接受重复的列名,可以通过传递 makeunique=true 关键字参数来实现:
julia> DataFrame(:a=>1, :a=>2; makeunique=true)
1×2 DataFrame
Row │ a a_1
│ Int64 Int64
─────┼──────────────
1 │ 1 2
总结
-
您可以使用@chain 宏创建数据处理管道。DataFrames.jl 和 DataFramesMeta.jl 提供的函数和宏与这个宏很好地集成,因为它们通常将输入数据框或分组数据框作为第一个位置参数,并返回一个数据框。
-
DataFrames.jl 定义了五个函数,允许你对数据框或分组数据框的列执行操作:combine、select、select!、transform 和 transform!。带有 ! 后缀的函数会就地修改传递给它们的对象,而没有 ! 后缀的函数则分配一个新的返回值。
-
combine 函数用于将(聚合)源对象中的行组合起来。select 和 transform 函数保持与源对象中相同的行数和顺序。它们之间的区别在于 select 只保留你指定的列,而 transform 还会保留源对象中的所有列。
-
如果你有一个作用于标量的函数,通过使用 ByRow 对象包装它,可以将它们转换为向量化的(接受)数据集合,并按元素应用原始函数。
-
在对列执行操作的函数中,使用的是通用的操作规范语法。它遵循通用模式 source_column => operation_function => target_column_name,但该模式中的某些元素可以被省略,如列表 13.3 所示。最常见的三种变体如下:(1) 传递 source_column 并将其存储在结果中而不做任何修改,(2) 传递 source_column => operation_function 会自动生成目标列名,以及(3) 传递 source_column => target_column_name 是用于列重命名的语法。
-
DataFramesMeta.jl 为 DataFrames.jl 中提供的所有列转换函数提供了宏。一个重要的规则是,宏名前可以加前缀 r。这个前缀表示宏中指定的操作应该自动向量化(按行执行)。例如,@select 和 @rselect 宏与 select 函数等价。区别在于 @select 中的操作作用于整个列,而 @rselect 中的操作作用于单个元素。
-
CategoricalArrays.jl 提供了对分类数组的支持。如果你想在统计意义上将数据视为名义或有序的,这种数据很有用。
-
DataFrames.jl 提供了允许你执行多个表的标准连接操作的函数。当你想要将来自几个源数据框的数据组合在一起时,会使用这些函数。
-
你使用 stack 和 unstack 函数在长格式和宽格式之间重塑数据框。在分析数据时,这些操作通常都是必需的。
-
subset 和 subset! 函数允许你对数据框的行进行子集化。它们使用与 combine 或 select 中相同的操作规范语法。DataFramesMeta.jl 提供了与这两个函数等价的宏。使用这些函数的好处,例如与数据框索引相比,是它们设计得易于在 @chain 操作中使用。
-
ROCAnalysis.jl 包提供了一套功能,允许您评估分类器的预测能力。这一功能在您每次构建具有二元目标变量的模型时都是必需的。
14 创建用于共享数据分析结果的 Web 服务
本章涵盖
-
实施蒙特卡洛模拟
-
在计算中使用多线程
-
在 Julia 中创建和运行 Web 服务
在第一章中,我们讨论了一个时间线案例研究。回想一下,时间线公司提供了一款帮助财务顾问进行退休财务规划的网络应用程序。该应用程序需要在快速响应时间的同时执行大量的按需计算。在本章中,我们将创建一个在简化设置中具有类似功能的 Web 服务。
假设我们正在一家为客户提供评估金融资产服务的公司工作。你被要求创建一个 Web 服务,用于对亚洲期权进行定价。亚洲期权是一种金融工具,其价格取决于一定时期内标的资产(例如,股票)的平均价格;在第 14.1 节中,我给出了该期权定义的详细信息。
由于亚洲期权是一种复杂的金融工具,其价值没有简单的公式。因此,您需要执行蒙特卡洛模拟来近似这个值。在进行蒙特卡洛模拟时,我们多次随机采样标的资产价格的变化。接下来,对于每条价格路径,我们计算亚洲期权的收益,并使用平均收益来近似期权的价值。
挑战在于蒙特卡洛模拟计算密集。因此,在本章中,您将学习如何利用 CPU 的多核,利用 Julia 对多线程的支持,尽可能快地产生所需的结果。
从工程角度来看,要求是您的 Web 服务接受 POST 请求中的 JSON 有效载荷,并返回 JSON 格式的响应。在这种情况下,POST 请求将数据发送到服务器,指定我们想要评估的亚洲期权的参数。这些参数以 JSON 格式传递到服务器;以这种方式传递的信息通常被称为JSON 有效载荷。您将通过使用 Genie.jl 包学习如何创建此类 Web 服务。
为了测试创建的 Web 服务,我们将编写一个客户端程序,该程序将分析亚洲期权的估值如何随着其参数的变化而变化。
本章分为以下几节:
-
第 14.1 节解释了使用蒙特卡洛模拟对亚洲期权进行定价的理论。
-
在第 14.2 节中,我们利用 Julia 提供的多线程支持来实现模拟。
-
在第 14.3 节中,我们使用 Genie.jl 包创建了一个可以响应对亚洲期权估值请求的 Web 服务。
-
在第 14.4 节中,我们通过编写一个客户端程序向创建的 Web 服务发送请求并获取返回的响应来测试该 Web 服务。
14.1 使用蒙特卡洛模拟对金融期权进行定价
在本节中,你将通过使用蒙特卡洛模拟来学习亚洲期权定价背后的理论。这类定价模型在金融行业中普遍使用,因此了解它们是如何工作的细节是有用的。我们的例子改编自 Barry L. Nelson 的《随机模拟的基础与方法》(Springer,2013 年)。
14.1.1 计算亚洲期权的收益定义
我们首先给出我们考虑的亚洲期权的定义。这种期权的收益取决于一个基础金融工具。假设这个基础工具是股票。我们在一定时间内观察这只股票的价格。如果股票的平均价格高于称为行权价格的值,亚洲期权会给投资者带来收益。在这种情况下,亚洲期权的收益等于股票的平均价格减去行权价格。现在我将正式定义收益是如何计算的。
假设一只股票在市场上交易。我们用 X(t) 表示它在时间 t 的价格。为了简单起见,假设我们目前处于时间 t = 0,因此我们知道股票的价格是 X(0)。我们感兴趣的是从 t = 0 到 t = T 这段时间内股票的平均价格。在这段时间内,股票的价格变化了 m 次。因此,我们将看到它在时间 0,T/m,2T/m,...,(m - 2)T/m,,(m - 1)T/m, 和 T 的价格。我们用 Y 表示在这些 m + 1 个时间点上的股票平均价格。
我们考虑的亚洲期权有其估值规则。在时间 T,我们计算股票 Y 的平均价格。如果这个值大于价值 K(称为行权价格),我们得到 Y - K 的收益;否则,我们得不到任何收益。更正式地说,我们的收益是 max(Y - K, 0)。如果你有机器学习的经验,你会认识到这个函数通常被称为修正线性单元 (ReLU)。
在我们继续前进之前,让我们考虑这样一个定价的例子。假设我们有 T = 1.0,m = 4,和 K = 1.05。我们处于时间 T,我们看到了价格 X 为 1.0,1.1,1.3,1.2,1.2。因此,Y 等于 1.16,所以收益是 max(Y - K, 0) = 0.11。我们可以用以下代码可视化这个场景:
julia> using Plots
julia> using Statistics
julia> X = [1.0, 1.1, 1.3, 1.2, 1.2]
5-element Vector{Float64}:
1.0
1.1
1.3
1.2
1.2
julia> T = 1.0
1.0
julia> m = 4
4
julia> Y = mean(X)
1.1600000000000001
julia> K = 1.05
1.05
julia> plot(range(0.0, T; length=m+1), X;
xlabel="T", legend=false, color="black")
julia> hline!([Y], color="gray", lw=3, ls=:dash)
julia> hline!([K], color="gray", lw=3, ls=:dot)
julia> annotate!([(T, Y + 0.01, "Y"),
(T, K + 0.01, "K"),
(T, X[end] + 0.01, "X")])
在这个例子中,我们使用了三个新的函数。范围函数创建一个从第一个到第二个位置参数的等间距值的向量,长度关键字参数指定我们想要的点的数量。hline! 函数向图中添加一条水平线,annotate! 函数向其添加文本注释。annotate! 函数接受一个向量,其中每个元素都是一个元组,指定了 x 位置,y 位置和要显示的文本。
图 14.1 显示了我们的代码产生的结果。

图 14.1 由于 Y 高于 K,亚洲期权对于股票价格 X 给出了正的收益。
14.1.2 计算亚洲期权的价值
我们的任务是计算亚洲期权在时间 0 的价值。在这个时候,我们不知道 Y。那么,我们亚洲期权的公平价值是什么?
假想我们能够多次购买这样的期权并观察我们期权所依据的股票价格的演变。我们期权的价值被定义为在这种实验中我们可能期望的平均收益。正式来说,使用概率论的语言,我们说我们想要通过 E(max(Y - K, 0)) 来计算我们收益的期望值。然而,我们需要考虑一个额外的因素。由于收益是在时间 T 收集的,而我们处于时间 0,我们需要对其进行折现。假设 r 是无风险利率,我们使用连续复利(这是金融计算中常见的假设;例如,参见 mng.bz/AV87)。因此,我们需要将收益的期望值乘以折现因子 exp(-rT)。总之,期权在时间 0 的价值是 exp(-rT)·E(max(Y - K, 0))。
计算所需价值的挑战在于,在时间 0,Y 的价值是未知的。我们将假设在 0 到 T 期间股票的价格遵循几何布朗运动(GBM,mng.bz/ZpRa)。这个随机过程常用于模拟金融资产的价格。
14.1.3 理解 GBM
通常,GBM 过程被介绍为随机微分方程的解,但就我们的目的而言,你只需要有一个直观的理解即可。其思路如下。如果我们有一个随机过程 X(t) 代表股票价格,我们希望这个价格在两个时间 t[1] 和 t[2](其中 t[1] < t[2])之间的比率的对数遵循正态分布。这个比率可以用公式 log(X(t[2])/X(t[1])) 表示,称为 log return (mng.bz/RvmO)。由于我们假设 log return 遵循正态分布,因此我们应该指定这个分布的均值和方差。
让我们从股票价格对数收益率的分布的方差开始。在 GBM 模型中,我们假设方差等于s²(t[2] - t[1]),其中s是一个参数。正如你所看到的,方差与t[2]和t[1]之间的差异成正比。为了理解为什么这是一个自然的假设,考虑三个时间段,t[1] < t[2] < t[3]。GBM 模型中的假设是,时间段t[1]和t[2],以及t[2]和t[3]之间的对数收益率是独立的。观察 log(X(t[2])/X(t[1])) + log(X(t[3])/X(t[2])) = log(X(t[3])/X(t[1])). 因此,如果我们想让 GBM 模型保持一致,方程左边两个项的方差之和必须等于方程右边项的方差。确实如此,因为s²(t[2] - t[1]) + s²(t[3] - t[2]) = s²(t[3] - t[1]).
我们现在准备转向股票价格对数收益率的分布的期望值。在这里,我在书中提出的假设是,预期股票价格的对数收益率应该等于无风险资产的对数收益率。回想一下,我们用r来表示连续复利下的无风险利率。这意味着r(t[2] - t[1])应该等于 log(E(X(t[2]))/E(X(t[1]))). 可以证明,如果我们想使这个性质成立,股票价格对数收益率的分布的均值应该等于(r - s²/2)(t[2] - t[1])。
总结来说,在我们的几何布朗运动模型中,我们假设股票价格对数(X(t[2])/X(t[1]))的收益率服从均值为(r - s²/2)(t[2] - t[1])和标准差s²(t[3] - t[1])的正态分布。让我们将这个假设转换为我们实现中使用的参数化。回想一下,我们假设股票价格是在时间 0,T/m,2T/m,...,(m - 2)T/m,(m - 1)T/m,和T时测量的。在这些假设下,比率X((I + 1)T/m)/X(iT/m)的值是随机变量 exp((r - s²/2)T/m + s²(T/m)·Z(i)),其中Z(0),Z(1),...,Z(m - 2),Z(m - 1)是独立同分布的随机变量,具有均值为 0 和标准差 1 的正态分布。在这个公式中,r是无风险利率,s*是股票价格变动的度量。
在我们继续前进之前,让我们看看生成 GBM 过程单个样本的最小示例。在计算中,我们使用X(0) = 1.0,T = 2.0,s = 0.2,r = 0.1,和m = 4。
在代码中,我们将 GBM 过程单个样本的模拟结果收集到一个具有两列的数据框中:模拟股票价格 X 和时间 t。然后,我们迭代地抽取股票价格在两个连续时间段之间的 log 返回值。使用它,我们计算更新的股票价格,并使用 push! 函数将其存储为 gbm 数据框的新行:
julia> using DataFrames
julia> using Random
julia> Random.seed!(1234); ❶
julia> X0, T, s, r, m = 1.0, 2.0, 0.2, 0.1, 4 ❷
(1.0, 2.0, 0.2, 0.1, 4)
julia> gbm = DataFrame(X=X0, t=0.0) ❸
1×2 DataFrame
Row │ X t
│ Float64 Float64
─────┼─────────────────
1 │ 1.0 0.0
julia> for i in 1:m
Z = randn() ❹
log_return = (r - s²/2) * T/m + s * sqrt(T/m) * Z ❺
next_X = gbm.X[end] * exp(log_return) ❻
next_t = gbm.t[end] + T/m
push!(gbm, (next_X, next_t)) ❼
end
julia> gbm
5×2 DataFrame
Row │ X t
│ Float64 Float64
─────┼──────────────────
1 │ 1.0 0.0
2 │ 0.989186 0.5
3 │ 1.20067 1.0
4 │ 1.17768 1.5
5 │ 1.35691 2.0
❶ 设置随机数生成器的种子以确保示例的可重复性
❷ 使用单个表达式为几个逗号分隔的变量赋值
❸ 初始化将存储模拟结果的 gbm 数据框
❹ 使用 randn 函数从标准正态分布中生成一个随机值
❺ 计算两个连续时间段之间股票价格的 log 返回值
❻ 根据最后存储的价格和 log 返回值计算下一期的股票价格
❼ 向数据框中添加一行,包含股票价格和记录时间的信息
在示例中,gbm 数据框中存储的股票价格在周期之间随机变化,因为在计算 log_return 变量时,我们有一个由 Z 变量表示的随机成分。
14.1.4 使用数值方法计算亚洲期权价值
描述 GBM 过程的公式看起来很复杂。这表明没有简单的方法可以让我们计算亚洲期权的价值;回想一下,这个价值被定义为 exp(-rT)·E(max(Y - K, 0))。实际上,在这种情况下,这个表达式的闭式公式不存在。我们应该如何计算它的值?我们将使用蒙特卡洛模拟来近似它。蒙特卡洛模拟的算法如下。
在我们的蒙特卡洛模拟的单个步骤中,我们需要为 GBM 过程的单个实现计算亚洲期权的收益。因此,我们需要执行以下操作:
-
从标准正态分布中独立抽取随机值 Z(0), Z(1), . . . , Z(m - 2), Z(m - 1)。
-
使用生成的随机值计算股票价格 X(0), X(T/m), . . . , X((m - 1)/T), X(T)。
-
将 Y 计算为计算出的股票价格的平均值。
-
计算 V = exp(-rT)·max(Y - K, 0)。
在我们的蒙特卡洛模拟中,我们独立重复这个过程很多次。用 n 表示执行此单个步骤的次数。因此,我们将收集 n 个值 V(1), V(2), . . . , V(n - 1), V(n)。这些值的平均值近似于我们的亚洲期权价值 exp(-rT)·E(max(Y - K, 0))。然而,由于我描述的过程是随机的,这个值不是精确的。在这种情况下,我们通常希望以某种方式量化这种不确定性。因此,我们将计算亚洲期权价格的 95% 置信区间。
95%置信区间是一个范围,如果我们运行模拟并多次计算,95%的区间将包含我们亚洲期权价格的真实(未知)值(www.simplypsychology.org/confidence-interval.html)。如果我们有一组 n 个独立的观察值,其均值为 m,标准差为 sd,那么我们将使用以下公式在本章中近似 95%置信区间:[m - 1.96sd/√n, m + 1.96sd/√n]。
在我们继续实现亚洲期权估值算法之前,我将总结我们需要知道以计算我们考虑的亚洲期权价值所需的参数:
-
时间范围 T
-
股票的起始价格 X(0)
-
执行价格 K
-
无风险利率 r
-
股票价格波动率 s
-
股票价格变动次数 m
我们假设请求亚洲期权估值用户知道这些参数。
另一个技术参数是 n,它是模拟重复的次数。这个参数将影响我们计算亚洲期权价格时的精度。
我们准备编写代码来计算亚洲期权的近似价值及其 95%置信区间。此外,在我们的实现中,我们还想确定期权收益为零的概率。
14.2 实现期权定价模拟器
在本节中,你将学习如何实现根据 14.1 节最后部分描述来定价亚洲期权的模拟器。首先,我们将创建一个函数,用于计算单个股票价格样本下我们期权的收益。接下来,我们将创建一个函数,通过蒙特卡洛模拟来计算期权的近似价格。
在此过程中,我们将使用多线程,以便我们可以快速获得计算结果。学习如何编写多线程代码是有用的,因为它可以有效地使用你的 CPU 核心,从而更快地提供计算结果。
14.2.1 使用多线程支持的 Julia 启动
在本节中,你将学习如何以多线程支持的方式启动 Julia。当你想要在计算中使用 CPU 的全部能力时,这非常有用。
在我们的计算中,我们希望使用多个线程,因此以四个线程的支持启动 Julia。这是通过传递-t4 开关来完成的。如果你在终端工作,你应该在我们存储与本书相关的代码存储库的目录中。输入以下内容:
julia --project -t4
如果你使用 Visual Studio Code(见附录 A),你可以在 Julia 扩展的设置中设置 Julia 会话应使用的线程数。
我假设您电脑上的处理器至少有四个物理核心。如果您的处理器不符合这一要求,代码仍然可以运行,但您可能看不到我展示的性能提升。
一旦您使用四个线程启动 Julia 会话,我们首先检查是否正确设置了环境。Threads.nthreads 函数返回 Julia 进程可用的线程数:
julia> Threads.nthreads()
4
如预期,我们得到 4,这意味着支持多线程的代码将能够使用您 CPU 的四个核心。
启动 Julia
在启动 Julia 进程时,您可以选择多个选项。您可以通过在终端中输入 julia --help 来获取这些选项的列表,或者检查 Julia 手册中的“命令行选项”部分(mng.bz/2r7m)。
这里是一个可用的开关列表:
-
-tN,其中 N 是数字—设置 Julia 使用的线程数为 N。在 Visual Studio Code 中,您可以在 Julia 扩展的设置中设置 N 的值。
-
--project—将当前工作目录设置为家目录项目(参见附录 A 的解释)。如果您使用终端,在启动 Julia 时,对于本书中展示的所有代码,您应该使用此选项。在 Visual Studio Code 中,如果您之前已经启动了一个 Julia 会话并且打开了一个包含 Project.toml 和 Manifest.toml 文件的代码仓库目录,则此选项默认使用。
-
-pN,其中 N 是数字—设置 Julia 应在此计算机上启动的额外 worker 进程数。(此选项用于分布式计算。本书中不讨论此主题;有关详细信息,请参阅 Julia 手册中的“多进程和分布式计算”部分
mng.bz/19Jn)。 -
--machine-file <文件>—在<文件>中列出的主机上启动额外的 worker 进程。(此选项用于分布式计算。本书中不讨论此主题;有关详细信息,请参阅 Julia 手册中的“多进程和分布式计算”部分
mng.bz/19Jn)。 -
--depwarn={yes│no│error}—决定 Julia 是否应打印弃用警告(通常在您使用某些已弃用且可能在未来被删除或更改的功能时发出)。默认情况下,不打印弃用警告;如果此选项设置为 error,则所有弃用都转换为错误。
-
-ON,其中 N 是 0 到 3 之间的数字—设置编译器的优化级别。默认值为 2(优化级别越高,编译器执行的优化越多)。
14.2.2 计算单个股票价格样本的期权收益
在本节中,我们将实现一个函数,用于计算本章中考虑的亚洲期权的收益,针对单个股票价格样本。我们还将检查是否可以使用多个线程来加快计算该收益的代码的执行速度。
我们的代码直接遵循第 14.1 节中提出的规范。在 X 变量中,我们保持股票的当前价格,而在 sumX 变量中,我们保持所有观察到的股票价格的总和。我们更新 X 和 sumX 变量 m 次。X 变量被迭代地乘以 exp((r - s²/2)T/m + s²(T/m)Z(i))表达式的样本。回想一下,Z(i)是均值为 0,标准差为 1 的正态分布。我们可以在 Julia 中使用 randn 函数来采样这个值。列表 14.1 展示了计算一个股票价格轨迹样本的期权收益的 payoff_asian_sample 函数的完整实现。
注意在列表 14.1 中,我在 payoff_asian_sample(T, X0, K, r, s, m)签名后添加了::Float64 类型注释。这样,我确保这个函数的返回值被转换为 Float64 值。换句话说,这个函数总是返回一个具有 Float64 类型的数字。
此注释是可选的,但有时很有用。首先,它明确地传达了函数开发者的意图,因此作为文档很有用。其次,如果我们错误地尝试从这个函数返回一个无法转换为 Float64 的值,我们会得到一个错误,这将使我们能够更快地捕获潜在的 bug。最后,既然我们知道这个函数返回 Float64,那么在某些情况下,使用这个函数编写的代码可以稍微简单一些。例如,当我们想要预分配一个将存储产生的值的集合时,我们可以安全地将它的元素类型声明为 Float64。
列表 14.1 计算亚洲期权的价格轨迹样本的收益
function payoff_asian_sample(T, X0, K, r, s, m)::Float64
X = X0 ❶
sumX = X ❷
d = T / m ❸
for i in 1:m
X *= exp((r - s² / 2) * d + s * sqrt(d) * randn()) ❹
sumX += X
end
Y = sumX / (m + 1) ❺
return exp(-r * T) * max(Y - K, 0)
end
❶ X 变量跟踪当前股票价格。
❷ sumX 变量累积所有观察到的股票价格的总和。
❸ d 变量具有等于股票价格测量时间差的值。
❹ 通过使用根据 GBM 公式计算的日志回报来改变股票价格
❺ Y 变量存储了考虑期间的平均股票价格。
执行 payoff_asian_sample 函数涉及随机数生成,因此多次运行它会产生不同的结果。例如,让我们检查 T=1.0,X0=50.0,K=55.0,r=0.05,s=0.3 和 m=200 的参数:
julia> payoff_asian_sample(1.0, 50.0, 55.0, 0.05, 0.3, 200)
0.0
julia> payoff_asian_sample(1.0, 50.0, 55.0, 0.05, 0.3, 200)
1.3277342904015152
julia> payoff_asian_sample(1.0, 50.0, 55.0, 0.05, 0.3, 200)
4.645749241587061
如果你运行这个示例,预期会得到不同的结果,因为我们没有设置随机数生成器的种子。
如第 14.1 节所述,我们想要多次运行 payoff_asian_sample 来近似亚洲期权的价值。为了这个示例的目的,我们基准测试了运行这个函数 10,000 次所需的时间,并将结果收集在一个新分配的向量中:
julia> using BenchmarkTools
julia> @btime map(i -> payoff_asian_sample(1.0, 50.0, 55.0, 0.05, 0.3, 200),
1:10_000);
21.783 ms (2 allocations: 78.17 KiB)
我们能否使这个模拟更快?我们可以,其中一种方法就是使用多线程。我们将使用 ThreadsX.jl 包中的 map 函数。这是一个 map 函数的多线程版本,它将生成多个任务(非正式地说,是可独立执行的代码部分),这些任务将并行运行以使用 Julia 会话中可用的所有线程:
julia> using ThreadsX
julia> @btime ThreadsX.map(i -> payoff_asian_sample(1.0, 50.0, 55.0, 0.05,
0.3, 200), 1:10_000);
5.821 ms (413 allocations: 845.89 KiB)
我们的代码运行速度几乎快了四倍,因此在这种情况下使用多线程是合理的。
经验丰富的程序员会注意到,当我们开始使用 map 函数的并行化版本时,需要考虑一个问题。payoff_asian_sample 函数在调用 randn 函数时使用随机数生成器。因此,问题是是否安全地在并行中运行此函数,因为它可能导致竞争条件(mng.bz/PoRv),因为当我们生成伪随机数时,我们会更新它们的生成器的内部状态。答案是代码是正确的,因为 Julia 中的每个任务都使用随机数生成器的单独实例,所以任务之间不会相互干扰。
ThreadsX.jl 包
ThreadsX.jl 包提供了 Base Julia 中可用函数的并行化版本。以下是一个实现所选函数的列表:any、all、map、prod、reduce、collect、sort、minimum、maximum、sum 和 unique。更多详细信息请参阅包文档(github.com/tkf/ThreadsX.jl)。
记住,当使用可以接受函数作为参数的函数时,我们必须确保使用它们不会导致竞争条件错误。
作为更高级的主题,值得补充的是,在 ThreadsX.jl 包中定义的函数旨在支持即使在使用不同数量的线程的会话中,产生的结果的可重复性。为了确保这种可重复性,请使用 basesize 关键字参数来指定每个任务中要处理的输入元素数量。
14.2.3 计算期权价值
在本节中,我们将实现一个近似亚洲期权价值的函数。此外,它将通过计算价值的 95%置信区间来返回结果的不确定性评估,并计算期权给出零收益的概率。
实现执行亚洲期权估值的函数
回想一下第 14.1 节,为了近似亚洲期权的价值,我们将使用蒙特卡洛模拟。因此,我们需要多次运行第 14.1 节中定义的 payoff_asian_sample 函数。但我们应该运行多少次呢?在本节中,我们将使用以下方法。我们不会指定第 14.1 节公式中给出的重复次数n,而是允许用户指定模拟允许运行的时间,我们将在此时间预算内计算尽可能多的重复次数。
我们之所以倾向于设置计算时间限制,是因为我们稍后将在一个网络服务中使用这个函数,我们希望能够控制网络服务的响应时间,以确保潜在的用户不需要等待很长时间才能得到结果。回想一下,在第一章的 Timeline 案例研究中,网络应用响应的长时间等待是公司决定转向 Julia 的原因之一。
为了控制最大计算时间,我们将使用 ThreadsX.map 函数以每批 10,000 个元素的方式运行 payoff_asian_sample 函数,就像我们在第 14.2.2 节中所做的那样,直到达到用户指定的计算时间。我们将反复将 ThreadsX.map 函数的结果追加到单个结果集合中。在完成第 14.1 节中给出的公式计算后,我们将计算期权的近似价值、价格的 95% 置信区间以及期权产生零收益的概率。这些步骤在以下列表中的 asian_value 函数中实现。
列表 14.2 近似亚洲期权价值的函数
using Statistics
function asian_value(T, X0, K, r, s, m, max_time)
result = Float64[] ❶
start_time = time() ❷
while time() - start_time < max_time ❸
append!(result, ThreadsX.map(i -> payoff_asian_sample(T, X0, K, ❸
r, s, m), ❸
1:10_000)) ❸
end
n = length(result)
mv = mean(result)
sdv = std(result)
lo95 = mv - 1.96 * sdv / sqrt(n)
hi95 = mv + 1.96 * sdv / sqrt(n)
zero = mean(==(0), result) ❹
return (; n, mv, lo95, hi95, zero) ❺
end
❶ 结果变量存储了以 10,000 个元素批次计算的模拟收益。
❷ 记录开始模拟的时间
❸ 在不超过 max_time 计算时间的情况下,添加一个包含 10,000 个元素的模拟收益批次
❹ (0) 语法是 x -> x == 0 匿名函数的简写。
❺ (; n, mv, lo95, hi95, zero) 语法是 (n=n, mv=mv, lo95=lo95, hi95=hi95, zero=zero) 的简写。
传递给 asian_value 函数的 max_time 参数应以秒为单位给出。在函数中,我们使用 time 函数获取以秒为单位的当前时间。这个测量具有微秒分辨率。特别是,time() - start_time 表达式测量自函数体中存在的 while 循环开始以来的时间,以秒为单位。
在 while 循环中,我们以每批 10,000 个元素的方式计算亚洲期权的模拟收益。我们知道,根据第 14.2 节,使用四个线程计算每个批次大约需要 5 毫秒。我们通过使用 append! 函数将 10,000 个元素的批次追加到结果向量中。
循环结束后,我们使用第 14.1 节中的公式计算以下统计数据:平均收益、其 95% 置信区间以及收益为 0 的概率。
在 mean(==(0), result) 表达式中,我们使用了在第四章中为求和函数学习的相同模式。均值函数接受一个函数作为其第一个参数,一个集合作为其第二个参数。然后它通过传递的函数转换这些集合中存储的值,有效地计算这些值的均值。以下是一个此模式应用的简单示例:
julia> mean(x -> x ^ 2, 1:5)
11.0
操作的结果是 11.0,因为我们计算了从 1 到 5 的整数的平方的平均值——即 (1² + 2² + 3² + 4² + 5²) / 5。
使用部分函数应用语法
另一个需要注意的表达式是==(0)。你知道,通常,==运算符需要两个参数并检查它们的相等性。==(0)是一个部分函数应用操作(mng.bz/JVda)。它将==操作右侧固定为等于 0。
==(0)的结果是一个期望一个参数并通过使用==运算符将其与 0 进行比较的函数。因此,你可以将==(0)视为等同于定义一个匿名函数x -> x == 0,该函数将操作右侧固定为 0。以下是一个使用由==(0)返回的函数的示例:
julia> eq0 = ==(0)
(::Base.Fix2{typeof(==), Int64}) (generic function with 1 method)
julia> eq0(1)
false
julia> eq0(0)
true
支持部分函数应用的运算
部分函数应用相当方便,因为它与使用匿名函数相比,使代码更易于阅读。
此外,重复定义匿名函数每次都会创建一个新的函数,这需要每次编译(这是一个更高级的话题)。另一方面,部分函数应用只创建一次定义,因此从编译延迟的角度来看也是首选的。
因此,除了==之外,常见的运算符如>, >=, <, <=, 和 isequal 也支持部分函数应用模式。
练习 14.1 使用@time宏,比较使用<(0)和x -> x < 0函数将值范围-10⁶:10⁶ 转换后的平均值计算时间。同时检查预定义lt0(x) = x < 0函数时的计时。运行每个操作三次。
使用创建命名元组的便捷语法
你在列表 14.2 中学习到的最后一个新元素是一种简化从变量创建命名元组的符号。如果你在括号中放入一个分号 (😉 后跟一个以逗号分隔的变量列表,Julia 会创建一个命名元组,其字段名称是你使用的变量的名称,其值是这些变量的值。以下是一个示例:
julia> val1 = 10
10
julia> val2 = "x"
"x"
julia> (; val1, val2)
(val1 = 10, val2 = "x")
结果等同于编写(val1=val1, val2=val2),但更短,更方便输入和阅读。当你想从一个函数中返回一个命名元组并想存储一些在这个函数内部计算过的变量时,通常使用这种模式。这就是我们在asian_value函数中所做的。
测试亚洲期权估值函数
在我们继续前进之前,让我们测试一下我们的asian_value函数,给它 0.25 秒的计算时间,并保持所有其他参数与第 14.2.2 节测试中的值相同:
julia> @time asian_value(1.0, 50.0, 55.0, 0.05, 0.3, 200, 0.25)
0.253619 seconds (19.76 k allocations: 30.483 MiB, 3.05% gc time,
18.78% compilation time)
(n = 300000, mv = 2.02427932454885, lo95 = 2.008366009684488,
hi95 = 2.040192639413212, zero = 0.6931733333333333)
julia> @time asian_value(1.0, 50.0, 55.0, 0.05, 0.3, 200, 0.25)
0.255105 seconds (17.30 k allocations: 39.226 MiB)
(n = 410000, mv = 2.026794692695957, lo95 = 2.01310815351485,
hi95 = 2.0404812318770635, zero = 0.6940609756097561)
julia> @time asian_value(1.0, 50.0, 55.0, 0.05, 0.3, 200, 0.25)
0.252337 seconds (16.89 k allocations: 38.398 MiB)
(n = 370000, mv = 2.0317793342921395, lo95 = 2.0173863909563443,
hi95 = 2.0461722776279347, zero = 0.6943594594594594)
每次函数执行时间略超过 0.25 秒。第一次运行时,它能够运行 300,000 步的模拟,而第二次和第三次运行时,分别运行 410,000 步和 370,000 步。这种差异发生是因为在第一次运行期间,Julia 还需要执行代码的编译。
从计算的角度来看,请注意,所有三次模拟运行获得的结果相似。在 0.25 秒的时间预算下,95%置信区间的宽度大约为 0.03。为了本章的目的,让我们假设这从最终用户的角度来看是可以接受的。
Julia 中的多线程
在本节中,你学习了 ThreadsX.jl 包,它提供了一个高级 API,允许你通过使用多个线程方便地执行典型操作。
Julia 的基础库也包含一个 Threads 模块,该模块提供了一个低级 API,允许你编写多线程代码。这个 API 的关键元素是 Threads.@spawn 宏(mng.bz/wyja),它创建一个任务并将其调度到可用的线程上。Threads 模块还允许你使用锁(mng.bz/qoj6),这可以帮助你避免竞态条件问题,并支持原子操作(mng.bz/5mo8),这些操作是线程安全的。
许多 Julia 包都利用了多线程。例如,DataFrames.jl 中的一些选定的昂贵操作可以利用多个线程。你可以查看包文档以获取列表(mng.bz/69np)。
你可以在 Julia 手册的“多线程”部分找到关于 Threads 模块功能更详细的信息(mng.bz/o5ry)。
14.3 创建一个提供亚洲期权估值的 Web 服务
在本节中,我们将构建一个 Web 服务,它将允许我们通过超文本传输协议(HTTP)提供亚洲期权估值。对于那些想要复习的人,请参阅 MDN Web 文档网站上的“HTTP 概述”(mng.bz/ne1V)。
Web 服务目前是允许应用程序交换消息的最流行方法之一。当云计算变得流行时,它们的使用变得特别广泛,因为它们允许通过网络在软件程序之间进行通信,而不管创建它们的编程语言或运行它们的平台是什么。
14.3.1 构建 Web 服务的一般方法
我们希望 Web 服务能够这样工作。假设你有一个客户端应用程序和一个运行在服务器上的 Web 服务。我们希望客户端应用程序能够向服务器发送请求,请求亚洲期权的价格。作为回应,服务器返回计算出的价格信息。通信通过互联网,使用 HTTP 协议完成。图 14.2 展示了这个过程的高级视图。

图 14.2 客户端应用程序与服务器上运行的 Web 服务之间的通信是通过 HTTP 完成的。
客户端如何向服务器发送请求?在本节中,我将向您展示如何在 Julia 中使用 HTTP 的 POST 方法执行此操作。要发送此类请求,我们需要传递以下内容:
-
我们想要发送请求的地址。在本章中,我们将使用 http://127.0.0.1:8000,这是 Genie.jl 的默认设置,指的是当前设备(称为 本地主机)和端口号 8000。
-
请求头(请求元数据)。
-
请求体(请求数据)。
我们希望我们的网络服务能够接受以 JSON 格式发送数据的请求。(我们在第七章中讨论了 JSON 格式。)请求的格式应允许客户端传递我们感兴趣的亚洲期权的参数。为了简化,在 14.1 节中讨论的所有参数中,我们将允许用户传递执行价格 K 的值。我们还将发送一个额外的技术参数,即服务器在返回响应之前允许处理请求的时间。要向网络服务发送此类查询,我们需要在请求头中设置 Content-Type 元数据为 application/json。请求体应该是 JSON 格式的数据,指定 K 的值和期望的响应时间。
作为响应,网络服务应返回第 14.2 节中定义的 asian_value 函数产生的值——即使用的蒙特卡洛样本数量、亚洲期权价格的近似值、该价格的 95% 置信区间以及期权价值为零的概率。所有这些值也应使用 JSON 格式发送。我们已经在第七章中处理过这种响应类型。
要创建网络服务,我们将使用 Genie.jl 包。这个全栈式网络框架拥有开发网络应用程序所需的所有组件。在本节中,我将使用 Genie.jl 的有限和简化功能集。如果您想了解更多关于这个包的信息,请查看其文档(mng.bz/qomJ)。
Julia 中的网络开发
在本章中,我们只想构建网络服务。然而,通常 Julia 提供了构建生产级网络应用程序所需的全套工具。Genie 框架的三个关键组件如下:
-
Genie.jl—一个具有灵活请求路由器、WebSocket 支持、模板和身份验证等功能的网络框架
-
SearchLight.jl—提供对象关系映射(ORM;www.altexsoft.com/blog/object-relational-mapping/) 层,允许您连接到 PostgreSQL、MySQL 和 SQLite 数据库
-
Stipple.jl—一个用于构建交互式数据应用的响应式 UI 库
如果您想了解更多关于 Genie 框架的信息,请访问 genieframework.com。
14.3.2 使用 Genie.jl 创建网络服务
要设置一个简单的网络服务,请按照以下步骤操作:
-
加载 Genie.jl 包。
-
将 Genie.config.run_as_server 设置为 true,以便稍后 Genie.jl 服务器将同步启动;Julia 进程将仅在服务器启动后处理服务器操作。可见的效果是,用于启动服务器的 Genie.Server.up 函数将不会返回。
-
定义一个 URL 与应调用的 Julia 函数之间的映射,以便在接收到请求时将响应发送回客户端。这是通过使用 Genie.Router.route 函数实现的。
-
通过调用 Genie.Server.up()启动服务器。
此过程的一个关键要素是理解如何定义处理 POST 请求的函数。在本节中,我们希望我们的网络服务接受 JSON POST 有效负载;我们希望允许我们的网络服务的客户端在请求体中发送 JSON 格式的数据。我们还希望生成的响应以 JSON 格式呈现。
要处理 application/json POST 请求,请使用 Genie.Requests.jsonpayload 函数。然后您可以索引返回的对象以获取其字段。如果解析 JSON 失败,Genie.Requests.jsonpayload 返回 nothing。
要生成具有 application/json 内容类型的响应,请使用 Genie.Renderer.Json.json 函数。我们将传递 NamedTuple 值给它,因为它方便地将 JSON 转换为消息体,并添加适当的消息头。以下是一个示例:
julia> using Genie
julia> Genie.Renderer.Json.json((firstname="Bogumił", lastname="Kamiński"))
HTTP.Messages.Response:
"""
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{"firstname":"Bogumił","lastname":"Kamiński"}"""
我们现在已经知道了一切,可以创建一个网络服务。我已经将其代码放在 GitHub 仓库中单独的 ch14_server.jl 文件中。网络服务的代码在列表 14.3 中给出。唯一的新部分是网络服务的处理。payoff_asian_sample 和 asian_value 函数与我们第 14.2 节中定义的相同。
在代码中,我们为根("/")设置了一个接受 POST 有效负载的路由。我们使用 Genie.jl 的默认设置,因此我们可以向地址 http://127.0.0.1:8000 发送 POST 请求。对于此路由,我们首先将接收到的 JSON 有效负载存储在 message 变量中。然后我们有一个 try-catch-end 块,它尝试从 message 中获取数据并将其传递给 asian_value 函数。请注意,为了简单起见,我假设我们的网络服务只接受 asian_value 函数的 K 和 max_time 参数。
我们使用 float 函数确保 K 和 max_time 在传递给 asian_value 时都是浮点数。如果获取 JSON 请求和运行模拟的过程成功,我们将返回一个具有 OK 状态的消息和 asian_value 函数返回的值。如果发生任何错误(例如,参数 K 未传递或不是数字),将引发异常,并在块的 catch 部分返回 ERROR 状态和空字符串作为值。
列表 14.3 创建亚洲期权定价网络服务
using Genie
using Statistics
using ThreadsX
function payoff_asian_sample(T, X0, K, r, s, m)::Float64
X = X0
sumX = X
d = T / m
for i in 1:m
X *= exp((r - s² / 2) * d + s * sqrt(d) * randn())
sumX += X
end
Y = sumX / (m + 1)
return exp(-r * T) * max(Y - K, 0)
end
function asian_value(T, X0, K, r, s, m, max_time)
result = Float64[]
start_time = time()
while time() - start_time < max_time
append!(result,
ThreadsX.map(i -> payoff_asian_sample(T, X0, K, r, s, m),
1:10_000))
end
n = length(result)
mv = mean(result)
sdv = std(result)
lo95 = mv - 1.96 * sdv / sqrt(n)
hi95 = mv + 1.96 * sdv / sqrt(n)
zero = mean(==(0), result)
return (; n, mv, lo95, hi95, zero)
end
Genie.config.run_as_server = true ❶
Genie.Router.route("/", method=POST) do ❷
message = Genie.Requests.jsonpayload() ❸
return try
K = float(message["K"]) ❹
max_time = float(message["max_time"]) ❹
value = asian_value(1.0, 50.0, K, 0.05, 0.3, 200, max_time) ❹
Genie.Renderer.Json.json((status="OK", value=value)) ❹
catch ❹
Genie.Renderer.Json.json((status="ERROR", value="")) ❹
end
end
Genie.Server.up() ❺
❶ 配置 Genie.jl 以同步启动网络服务
❷ 使用 do-end 块定义一个匿名函数,该函数将在用户将 POST 请求传递到 Web 服务提供的地址根时被调用
❸ 解析在 POST 请求中发送的 JSON 有效负载
❹ 尝试获取参数并计算亚洲期权的估值;在成功时返回 OK 状态和值,否则返回 ERROR 状态
❺ 启动 Web 服务
14.3.3 运行 Web 服务
我们现在准备好启动我们的 Web 服务。打开一个新的终端窗口,切换到您已克隆 GitHub 存储库的文件夹,并运行 julia --project -t4 ch14_server.jl 命令。您应该看到以下输出:
$ julia --project -t4 ch14_server.jl
┌ Info:
└ Web Server starting at http://127.0.0.1:8000 - press Ctrl/Cmd+C to stop
the server.
现在我们有一个正在运行的服务器,因此我们可以连接到它。请注意,我们使用-t4 开关启动了四个线程。不要关闭此终端窗口。
在我们继续之前,让我添加一条注释。在 14.3 列表中,当我们收到一个错误的请求时,我们仍然通过使用 Genie.Renderer.Json.json((status="ERROR", value=""))表达式向客户端发送带有 200 OK 状态的响应。处理这种情况的另一种方法是将 400 Bad Request 响应返回。如果您希望以这个状态码响应,您应该在 14.3 列表的 catch 部分放置 Genie.Responses.setstatus(400)表达式。如果您想了解更多关于 HTTP 状态码的信息,您可以查看“HTTP 状态码注册表”页面(mng.bz/4965)。
14.4 使用亚洲期权定价 Web 服务
在本节中,您将学习如何向 Web 服务发送请求并解析收到的响应。学习如何做这件事是有用的,因为您的程序通常会需要使用第三方 Web 服务。为此,我们将使用您在第七章中了解到的 HTTP.jl 和 JSON3.jl 包。
例如,我们将检查在保持所有其他参数在 14.3 节中固定的值的情况下,我们的亚洲期权价值如何随着执行价格K在 30 到 80 之间的变化而变化。本节组织如下:
-
我们首先讨论如何向我们的 Web 服务发送单个 POST 请求。
-
我们在一个数据框中收集多个 POST 请求的结果。
-
收集到的结果具有复杂的结构,其中一列存储多个值。我们将每个列展开成多个列,以便于处理数据。
-
我们可视化计算结果,以验证提高执行价格K会降低我们的亚洲期权价值,并增加它给出零收益的概率。
14.4.1 向 Web 服务发送单个请求
启动一个新的 Julia 会话(记住不要终止我们运行 Web 服务的会话)。
我们首先发送一个 POST 请求到我们的网络服务。回想一下,它位于 http://127.0.0.1:8000。让我们获取 K=55.0 和 max_time=0.25 的响应,因为这些值我们在第 14.2 节中已经使用过。从高层次上讲,图 14.2 展示了我们的客户端和网络服务之间的通信过程:
julia> using HTTP
julia> using JSON3
julia> req = HTTP.post("http://127.0.0.1:8000",
["Content-Type" => "application/json"],
JSON3.write((K=55.0, max_time=0.25)))
HTTP.Messages.Response:
"""
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Server: Genie/Julia/1.7.2
Transfer-Encoding: chunked
{"status":"OK","value":{"n":190000,"mv":2.05363436780124,
"lo95":2.033372995802685,"hi95":2.0738957397997946,
"zero":0.6927631578947369}}"""
julia> JSON3.read(req.body)
JSON3.Object{Vector{UInt8}, Vector{UInt64}} with 2 entries:
:status => "OK"
:value => {...
我们已经在第七章中使用了 JSON3.read 函数,并且知道它可以解析 JSON 数据到 JSON 对象中,从而我们可以从中获取数据。JSON3.write 函数,我们在代码中也使用,执行一个相反的操作。它接受一个 Julia 对象并将其转换为 JSON 格式的字符串。让我们看看 JSON3.write((K=55.0, max_time=0.25))会产生什么,以确保它确实是正确格式的 JSON 数据:
julia> JSON3.write((K=55.0, max_time=0.25))
"{\"K\":55.0,\"max_time\":0.25}"
我们还没有使用 HTTP.post,因为在第七章中我们使用了 HTTP.get 函数。它们之间的区别如下。当你想要发送 POST 请求时,你使用 HTTP.post 函数,当你想要发送 GET 请求时,你使用 HTTP.get。我们的网络服务期望 POST 请求,因为我们需要向它传递 JSON 数据,称为JSON 有效负载(GET 请求不支持在请求有效负载中发送数据)。你可以在 W3Schools 网站上找到各种 HTTP 请求方法的概述(www.w3schools.com/tags/ref_httpmethods.asp)。
在 HTTP.post 方法中,我们传递以下参数:
-
我们想要查询的 URL(在我们的例子中是 http://127.0.0.1:8000)。
-
标头元数据。由于我们的内容是 application/json,我们传递了"Content-Type" => "application/json"这对;它被封装在一个向量中,因为我们可能还想在标头中传递更多的元数据。
-
请求的主体,它是 JSON 格式的。
现在暂时切换到运行我们网络服务的终端。你会注意到那里打印了一条消息,显示 POST 请求已被成功处理:
:
[ Info: POST / 200
现在检查一下,如果我们向网络服务发送一个不正确的请求会发生什么:
julia> HTTP.post("http://127.0.0.1:8000",
["Content-Type" => "application/json"],
JSON3.write((K="", max_time=0.25)))
HTTP.Messages.Response:
"""
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Server: Genie/Julia/1.7.2
Transfer-Encoding: chunked
{"status":"ERROR","value":""}"""
这次,由于我们将 K 参数作为空字符串而不是数字发送,我们得到了状态中的 ERROR 和一个空字符串作为值。因此,我们的网络服务中的错误处理似乎工作正常。
练习 14.2 创建一个接受包含单个元素 n 的 JSON 有效负载的网络服务,该元素是一个整数。它应该返回一个使用 rand 函数生成的 n 个随机数的向量,格式为 JSON。如果传递的请求不正确,应生成 400 Bad Request 响应。在您的本地计算机上运行此 Web 服务器,并测试它是否按预期工作。
14.4.2 在数据框中收集来自网络服务的多个请求的响应
现在我们可以收集 K 从 30 变到 80 时我们的亚洲期权的估值。在下面的代码中,我们首先创建一个数据框,其中每一行存储我们想要检查的 K 的值。此外,在这个数据框中,我们存储我们想要使用的 max_time(在示例中,它被固定为等于 0.25)。
接下来,对于数据框的每一行,我们运行本节讨论的 HTTP.post 函数。为了实现这一点,我们使用 map 函数,将数据框的 K 和 max_time 列作为我们想要迭代的集合传递。我们将从网络服务获取的结果存储在 data 列中。由于获取结果的过程耗时较长,我们使用@show K 宏调用打印我们正在处理的 K 的值。这样,我们可以轻松地观察计算进度。此外,我们打印网络服务生成响应的时间,以确认它确实大约为 0.25 秒:
julia> using DataFrames
julia> df = DataFrame(K=30:2:80, max_time=0.25) ❶
26×2 DataFrame
Row │ K max_time
│ Int64 Float64
─────┼─────────────────
1 │ 30 0.25
2 │ 32 0.25
3 │ 34 0.25
: │ : :
24 │ 76 0.25
25 │ 78 0.25
26 │ 80 0.25
20 rows omitted
julia> df.data = map(df.K, df.max_time) do K, max_time ❷
@show K
@time req = HTTP.post("http://127.0.0.1:8000",
["Content-Type" => "application/json"],
JSON3.write((;K, max_time)))
return JSON3.read(req.body)
end;
K = 30
0.273856 seconds (194 allocations: 148.000 KiB)
K = 32
0.282072 seconds (194 allocations: 12.500 KiB)
K = 34
0.274884 seconds (194 allocations: 11.953 KiB)
...
K = 76
0.271959 seconds (193 allocations: 11.609 KiB)
K = 78
0.261515 seconds (194 allocations: 11.906 KiB)
K = 80
0.269932 seconds (193 allocations: 11.625 KiB)
❶ 初始时,数据框用我们想要计算亚洲期权价值的执行价格 K 的值以及每次运行允许的最大计算时间填充
❷ 使用 map 函数,为每个执行价格 K 和计算时间的组合向我们的网络服务发送 POST 请求,并将结果存储在数据框的 data 列中
现在我们的 df 数据框有三个列:K,max_time 和 data。最后一列存储由网络服务返回的数据构建的 JSON3 对象:
julia> df
26×3 DataFrame
Row │ K max_time data
│ Int64 Float64 Object...
─────┼────────────────────────────────────────────────────
1 │ 30 0.25 {\n "status": "OK",\n "valu...
2 │ 32 0.25 {\n "status": "OK",\n "valu...
3 │ 34 0.25 {\n "status": "OK",\n "valu...
: │ : : :
24 │ 76 0.25 {\n "status": "OK",\n "valu...
25 │ 78 0.25 {\n "status": "OK",\n "valu...
26 │ 80 0.25 {\n "status": "OK",\n "valu...
20 rows omitted
首先,检查在所有情况下我们是否都收到请求的 OK 状态,这将表明网络服务没有问题地处理了它们。为了实现这一点,我们使用 all 函数。我们向这个函数传递两个位置参数。第一个参数是一个谓词函数——在这种情况下,是一个匿名函数,检查状态是否为 OK。第二个是我们想要检查谓词始终为真的元素集合。以下是执行检查的代码:
julia> all(x -> x.status == "OK", df.data)
true
对于所有查询,我们都会收到一个状态为 OK 的响应。
14.4.3 解包数据框的列
df 数据框中存储在 data 列中的信息稍微有些不便,因为它嵌套了。df.data 向量的每个元素都有一个内部结构。存储在 data 列中的向量的每个条目都有一个值元素。回想一下第 14.3 节,每个值元素内部存储了五个子元素:n,mv,lo95,hi95 和 zero。我们想要使用从这五个子元素中提取的数据在我们的数据框中创建五个新列。这个过程通常被称为解包。
图 14.3 展示了解包的一个示例。源数据框只有一个列 x。该列的每个元素都是一个包含字段 a 和 b 的 NamedTuple。当我们解包这样的列时,我们在目标数据框中创建新的列,称为 a 和 b,它们存储来自构成列 x 的命名元组的相应字段的值。

图 14.3 源数据框中的 x 列被解包到目标数据框中的 a 和 b 列。解包通常使处理存储的数据更容易。
我们可以使用在第十三章中已经使用过的 select 函数执行展开。然而,要执行此操作,我们需要学习操作指定语法的另一个新元素。
在第十二章和第十三章中,你学习了这种语法的通用结构是 source_column => operation_function => target_column_name。例如,当我们写 :a => sum => :sum_a 时,我们希望从源数据框中获取列 a,对其应用求和函数,并将其存储在目标数据框的 sum_a 列中。
如我们已讨论的,存储的 JSON 对象的价值元素有五个子元素:n、mv、lo95、hi95 和 zero。因此,如果我们想提取 n 元素,例如,我们可以编写以下转换指定(我们在第十三章讨论了 ByRow;回想一下,它将接受标量的函数转换为向量化的函数):
:data => ByRow(x -> x.value.n) => :n
然而,我们希望提取价值元素的五个子元素。我们可以编写五个这样的转换指定操作,但有一个更简单的方法。在操作指定语法中,target_column_name 通常是指列的名称。然而,我们可以传递一个特殊的 AsTable 表达式作为目标列名称。如果我们这样做,DataFrames.jl 将尝试将操作函数的返回值中存储的元素提取到多个列中。列的名称将自动使用展开的元素名称生成。这个过程通过示例最容易解释。假设,根据图 14.3,我们有一个数据框,其列 x 存储具有属性 a 和 b 的命名元组:
julia> small_df = DataFrame(x=[(a=1, b=2), (a=3, b=4), (a=5, b=6)])
3×1 DataFrame
Row │ x
│ NamedTup...
─────┼────────────────
1 │ (a = 1, b = 2)
2 │ (a = 3, b = 4)
3 │ (a = 5, b = 6)
我们现在希望将属性 :a 和 :b 展开为新的列。我们可以这样写:
julia> transform(small_df, :x => identity => AsTable)
3×3 DataFrame
Row │ x a b
│ NamedTup... Int64 Int64
─────┼──────────────────────────────
1 │ (a = 1, b = 2) 1 2
2 │ (a = 3, b = 4) 3 4
3 │ (a = 5, b = 6) 5 6
或者,如第十三章所述,如果不需要操作函数,可以省略它并这样写:
julia> transform(small_df, :x => AsTable)
3×3 DataFrame
Row │ x a b
│ NamedTup... Int64 Int64
─────┼──────────────────────────────
1 │ (a = 1, b = 2) 1 2
2 │ (a = 3, b = 4) 3 4
3 │ (a = 5, b = 6) 5 6
现在我们知道我们可以使用 AsTable 作为目标列名称,从 df 数据框的数据列中包含的 JSON 对象的价值元素中展开存储的数据。我们还希望保留 K 列,以了解哪一行代表哪个执行价格的价值。以下是执行所需操作的代码:
julia> df2 = select(df, :K, :data => ByRow(x -> x.value) => AsTable)
26×6 DataFrame
Row │ K n mv lo95 hi95 zero
│ Int64 Int64 Float64 Float64 Float64 Float64
─────┼────────────────────────────────────────────────────────────────
1 │ 30 420000 20.2203 20.1943 20.2462 0.000795238
2 │ 32 370000 18.3586 18.3309 18.3863 0.0030973
3 │ 34 410000 16.4279 16.4017 16.4541 0.00917805
: │ : : : : : :
24 │ 76 400000 0.0566558 0.054339 0.0589726 0.988712
25 │ 78 400000 0.0378442 0.035959 0.0397294 0.9924
26 │ 80 390000 0.0256958 0.0241446 0.027247 0.994672
20 rows omitted
14.4.4 亚洲期权定价结果的绘图
为了可视化获得的结果,我们将创建两个图表。在第一个图表中,我们想显示亚洲期权的价值作为执行价格的函数。在第二个图表中,该期权的零收益概率作为执行价格的函数被可视化。我们预计随着执行价格的上升,期权的价值会下降,零收益的概率会增加。我们使用以下代码生成图表:
julia> using Plots
julia> plot(plot(df2.K, df2.mv; legend=false,
xlabel="K", ylabel="expected value"),
plot(df2.K, df2.zero; legend=false,
xlabel="K", ylabel="probability of zero"))
图 14.4 显示了结果。

图 14.4 绘制亚洲期权的近似价值及其作为 K 的函数的零收益概率。我们期权的零收益概率越高,其价值就越低。
在我们完成工作之前,记得终止网络服务。前往你的网络服务正在运行的控制台窗口,然后按 Ctrl-C(在 Windows 和 Linux 上)或 Cmd-C(在 Mac 上)。现在终端应该显示系统提示,表明网络服务已终止。
摘要
-
亚洲期权是一种复杂的金融工具。它们通常没有封闭形式的公式来计算其价值。在这种情况下,你可以使用蒙特卡洛模拟来近似这个值。在蒙特卡洛模拟中,我们多次随机采样亚洲期权所依据的股票价格的变化,并对每个样本计算期权的收益。这些收益的平均值使我们能够计算亚洲期权的近似价值。
-
使用蒙特卡洛模拟对金融资产进行定价是计算密集型的。由于 Julia 是一种快速语言,因此它是这项任务的理想选择。
-
Julia 本地支持多线程。要允许你的 Julia 进程使用多个线程,在启动时传递 -tN 选项,其中 N 是所需的线程数。内置的多线程支持是区分 Julia 与 R 或 Python 的特性之一。这个功能允许你的程序运行得更快,因为它们可以利用 Julia 运行的多个 CPU 核心来提高性能。
-
Threads 模块为在 Julia 中编写多线程代码提供了低级 API。它提供了所有标准组件(如创建任务、原子操作和锁),这些都是编写使用多线程的高级代码所必需的。
-
ThreadsX.jl 包提供了一组高级 API,允许你轻松运行标准函数(例如,使用多线程进行映射)。
-
Threads 模块和 ThreadsX.jl 包的功能结合在一起,使得在 Julia 中轻松并行化简单操作成为可能,即使是非专家也能做到。此外,编写复杂的线程代码也是可能的,专家可以充分利用可用的 CPU 来获得最佳性能。
-
当你编写的代码有有限的计算时间预算,但你仍然希望它使用多线程时,一个有用的模式是分批处理数据。批处理的大小应该足够大,以便你能够获得多线程的好处,但也足够小,以至于对单个批次的计算只需要一小部分可用的时间预算。当你编写需要确保用户可以决定他们可以等待多长时间的应用程序时,这种技术通常很有用。
-
Julia 中的几个运算符(如 ==、> 和 <)允许部分函数应用。因此,编写 == (0) 与定义匿名函数 x -> x == 0 的效果相同。使用部分函数应用模式可以编写更易读的代码,并且比使用匿名函数需要更少的编译。
-
Genie.jl 包是一个全栈网络框架,允许你创建复杂的网络应用程序。在使用 Genie.jl 的数据科学工作流程中,你可以轻松创建网络服务,这些服务允许你使用 HTTP 来提供分析结果。
-
在设计网络服务时,你可以使用 HTTP POST 请求方法,允许客户端应用程序在请求体中向服务器发送数据。服务器通过请求的 Content-Type 头部的值来了解你数据的格式。
-
使用 Genie.jl,你可以设计你的网络服务,使其接受包含指定用户请求参数的 JSON 有效负载的 POST 请求,并返回 JSON 格式的响应。使用这种网络服务设计,你可以轻松地将 Julia 代码与其他编程语言编写的代码集成在一起,因为 HTTP 和 JSON 格式抽象出了你的网络服务的实现细节。
-
你可以通过在操作规范语法中使用 AsTable 作为目标列名,轻松地展开一个包含复杂结构(例如,命名元组或 JSON 对象)的数据帧中的列。当数据以层次结构组织时,这个功能非常有用,例如,当数据以 JSON 格式存储时。
附录 A Julia 的第一步
本附录涵盖
-
安装和设置 Julia
-
在 Julia 中获取帮助
-
寻找关于 Julia 的帮助信息的地方
-
在 Julia 中管理包
-
关于使用 Julia 的标准方式的概述
A.1 安装和设置 Julia
在本节中,我将解释如何获取、安装和配置您的 Julia 环境。首先,访问“下载 Julia”网页 (julialang.org/downloads/),并下载适合您所使用操作系统的 Julia 版本。
本书是用 Julia 1.7 编写和测试的。当您阅读本书时,可能在新版本的“下载”部分中可以获得更新的 Julia 版本。这不应该成为问题。我们使用的代码应该在 Julia 1.x 的任何新版本下都能运行。然而,例如,Julia 显示输出的方式可能会有细微的差异。如果您想使用与我编写本书时相同的 Julia 版本,您应该能够在“旧版未维护发布”页面 (julialang.org/downloads/oldreleases/) 上找到 Julia 1.7。
下载适合您操作系统的相应 Julia 版本后,转到“官方二进制文件的平台特定说明”页面 (julialang.org/downloads/platform/),并遵循您操作系统的设置说明。特别是,请确保将 Julia 添加到您的 PATH 环境变量中,以便 Julia 可执行文件可以在您的系统上轻松运行(说明是针对特定操作系统的,并在网站上提供)。在此过程之后,您应该能够通过打开终端并输入 julia 命令来启动 Julia。
以下是从终端运行的最小 Julia 会话。在运行示例之前,我在电脑上打开了一个终端。$ 符号是我机器上的操作系统提示符。
您首先通过输入 julia 命令来启动 Julia。然后显示 Julia 标签和 julia> 提示符,表示您现在可以执行 Julia 命令。要终止 Julia,请输入 exit() 命令并返回操作系统,这由 $ 提示符表示:
$ julia ❶
_
_ _ _(_)_ | Documentation: https://docs.julialang.org
(_) | (_) (_) |
_ _ _| |_ __ _ | Type "?" for help, "]?" for Pkg help.
| | | | | | |/ _` | |
| | |_| | | | (_| | | Version 1.7.2 (2022-02-06)
_/ |\__'_|_|_|\__'_| | Official https://julialang.org/ release
|__/ |
julia> exit() ❷
$ ❸
❶ 在操作系统提示符下,julia 命令启动 Julia。
❷ julia> 提示符表示我们处于一个 Julia 会话中。执行 exit() 函数将终止 Julia。
❸ 退出 Julia 后,我们回到了操作系统提示符。
在本书中,我始终使用这种展示输出的风格。您应该发送给 Julia 的所有命令都在 julia> 提示符之后给出。显示的其余文本是自动打印在终端上的内容。
A.2 在 Julia 中以及关于 Julia 获取帮助
在本节中,我将解释如何在 Julia 中获取帮助以及如何查找标准资源,您可以从这些资源中学习关于 Julia 的知识。
Julia 有一个内置的帮助模式。当您处于此模式时,Julia 将尝试打印关于您输入文本的文档。
这里有一个关于如何获取 && 运算符帮助的示例。从 Julia 提示符开始跟随示例。首先,按问号键输入“?”。提示符将从 julia> 变为 help?>,表示你已进入帮助模式。现在输入 && 并按 Enter 键以获取有关此运算符的信息:
julia> ? ❶
help?> &&
search: &&
x && y
Short-circuiting boolean AND.
See also &, the ternary operator ? :, and the manual section on control flow.
Examples
≡≡≡≡≡≡≡≡≡≡
julia> x = 3;
julia> x > 1 && x < 10 && x isa Int
true
julia> x < 0 && error("expected positive x")
false
❶ 在键入“?”后,提示符(就地)变为帮助?>。
通常,在 Julia 文档中,你会得到关于给定命令的解释,以及有关其他相关命令的信息和如何使用它的示例。
除了内置的帮助之外,Julia 在网络上还有广泛的文档可用。最重要的资源是 Julia 文档(docs.julialang.org/en/v1/),它分为三个部分。第一部分是语言的完整手册,第二部分涵盖了标准 Julia 安装中所有函数的文档,第三部分讨论了 Julia 的内部机制。
大多数包都有文档站点。例如,DataFrames.jl 包的文档位于 dataframes.juliadata.org/stable/。Julia 文档和为包创建的文档具有类似的设计。这是因为 Documenter.jl 包被用作从 docstrings 和 Markdown 文件构建文档的默认方法。
在 Julia 网站上,你可以在“学习”部分找到额外的教学材料链接(julialang.org/learning/)。它们包括 YouTube 视频、交互式教程以及一系列可以帮助你学习 Julia 工作各个方面的书籍。
Julia 的在线社区对于任何 Julia 用户来说都是重要的资源。如果你对 Julia 有任何疑问,我建议你从 Discourse 论坛开始(discourse.julialang.org)。对于更随意的对话,你可以使用 Slack (julialang.org/slack/) 或 Zulip (julialang.zulipchat.com/register/))。Julia 语言及其大多数包托管在 GitHub 上。因此,如果你想报告一个错误或提交一个功能请求,请在适当的 GitHub 仓库中打开一个问题。例如,对于 DataFrames.jl 包,你可以通过以下链接进行操作 github.com/JuliaData/DataFrames.jl/issues。
最后,Julia 在 Stack Overflow 上有 [julia] 标签的存在(stackoverflow.com/tags/julia)。
A.3 在 Julia 中管理包
Julia 的集成部分是其包管理器。它允许您安装和管理您可能在项目中想要使用的包。在本节中,我介绍了关于在 Julia 中管理包的最重要信息。关于此主题的深入讨论可在 Pkg.jl 文档(pkgdocs.julialang.org/v1/)中找到。
A.3.1 项目环境
讨论 Julia 中的包时的关键概念是环境:可以本地于单个项目或按名称共享和选择的独立包集合。环境中的包和版本的确切集合由 Project.toml 和 Manifest.toml 文件捕获。例如,在本书配套的 GitHub 存储库(github.com/bkamins/JuliaForDataAnalysis)中,您可以在根目录中找到这两个文件。您不需要手动编辑这些文件或详细了解其结构,但了解其内容是有用的。
Project.toml 文件指定了在给定项目中可以直接加载哪些包。以下是此文件的一部分,摘自本书的代码:
[deps]
Arrow = "69666777-d1a9-59fb-9406-91d4454c9d45"
BenchmarkTools = "6e4b80f9-dd63-53aa-95a3-0cdb28fa8baf"
CSV = "336ed68f-0bac-5ca0-87d4-7b16caf5d00b"
Manifest.toml 文件包含更多信息。它包括项目所需的所有包——即 Project.toml 文件中列出的包(称为直接依赖)以及为正确设置项目环境所需的所有其他包(Project.toml 中列出的包所需的包,称为间接依赖)。对于每个包,都给出了您项目中使用的确切版本信息。以下是此文件的一部分,摘自本书的代码:
# This file is machine-generated - editing it directly is not advised
julia_version = "1.7.2"
manifest_format = "2.0"
[[deps.AbstractFFTs]]
deps = ["ChainRulesCore", "LinearAlgebra"]
git-tree-sha1 = "69f7020bd72f069c219b5e8c236c1fa90d2cb409"
uuid = "621f4979-c628-5d54-868e-fcf4e3e8185c"
version = "1.2.1"
[[deps.Adapt]]
deps = ["LinearAlgebra"]
git-tree-sha1 = "af92965fb30777147966f58acb05da51c5616b5f"
uuid = "79e6a3ab-5dfb-504d-930d-738a2a938a0e"
version = "3.3.3"
总结来说,如果某个文件夹包含 Project.toml 和 Manifest.toml 文件,则它们定义了一个项目环境。
A.3.2 激活项目环境
启动您的 Julia 会话后,您可以通过按键盘上的方括号键(])进入包管理模式。当您这样做时,提示符将从 julia>变为 pkg>,表示您已进入包管理模式。默认情况下,此提示符将看起来像这样:
(@v1.7) pkg>
注意,在 pkg>提示符之前,您有(@v1.7)前缀。这表明 Julia 正在使用默认(全局)项目环境。默认环境由 Julia 为用户便利性提供,但建议不要在项目中依赖它,而是使用特定于项目的 Project.toml 和 Manifest.toml 文件。我将解释如何通过使用本书配套的 GitHub 存储库(github.com/bkamins/JuliaForDataAnalysis)来激活此环境。
在继续之前,请将此存储库下载到您的计算机上的一个文件夹中。在下面的示例中,我假设您已将此存储库存储在 D:\JuliaForDataAnalysis 文件夹中(这是一个 Windows 上的示例路径;在 Linux 或 macOS 上,路径将有所不同)。
要激活特定于项目的环境,你需要执行以下操作(这是最简单的情况):
-
使用 cd 函数将 Julia 的工作目录更改为 D:\JuliaForDataAnalysis 文件夹。
-
使用 isfile 函数检查工作目录中是否存在 Project.toml 和 Manifest.toml 文件。(这并非严格必要,但我包括这一步是为了确保你在工作目录中有这些文件。)
-
通过按下 ] 键切换到包管理器模式。
-
使用 activate . 命令激活项目环境。
-
可选地,通过使用 instantiate 命令来实例化环境。(这一步确保 Julia 从网络下载所有必需的包,如果你是第一次使用项目环境,则此步骤是必需的。)
-
通过按下退格键离开包管理器模式。
这里是这些步骤的代码:
julia> cd("D:/JuliaForDataAnalysis") ❶
julia> isfile("Project.toml")
true
julia> isfile("Manifest.toml")
true
(@v1.7) pkg> activate . ❷
Activating project at `D:\JuliaForDataAnalysis`
(JuliaForDataAnalysis) pkg> instantiate
julia> ❸
❶ 在 Windows 中,你可以使用斜杠 (/) 而不是反斜杠 () 作为路径分隔符。
❷ 按下 ] 键切换到包管理器模式。
❸ 按下退格键返回到 Julia 模式。
注意,在 Windows 中,你可以使用斜杠 (/) 而不是标准反斜杠 () 作为路径分隔符。
在 activate 命令中,我们传递一个点 (.),这表示 Julia 的当前工作目录。我们可以通过 cd 函数避免更改工作目录,而是在 activate 命令中传递环境的路径,如下所示:
(@v1.7) pkg> activate D:/JuliaForDataAnalysis
Activating project at `D:\JuliaForDataAnalysis`
(JuliaForDataAnalysis) pkg>
我更喜欢将 Julia 的工作目录切换到存储 Project.toml 和 Manifest.toml 文件的地方,因为通常它们存储在其他项目文件(如 Julia 代码或源数据)相同的目录中。
观察到更改项目环境后,其名称将作为 pkg> 提示符的前缀显示。在我们的例子中,这个前缀是 (JuliaForDataAnalysis)。
在激活环境后,你将执行的所有操作(例如,使用包或添加或删除包)都将在这个激活的环境中完成。
在以下典型场景中,项目环境的激活被简化了:
-
如果你在一个包含 Project.toml 和 Manifest.toml 文件的文件夹中的终端操作系统提示符下,那么当你使用 julia --project 调用启动 Julia 时,由这些文件定义的项目环境将自动激活。
-
如果你正在使用 Visual Studio Code(在第 A.4 节中讨论),并且已经打开包含 Project.toml 和 Manifest.toml 文件的文件夹,然后启动 Julia 服务器,Visual Studio Code 将自动激活由这些文件定义的项目环境。
-
如果你正在使用 Jupyter 交互式环境(在第 A.4 节中讨论),那么,类似于前面的场景,如果包含 Jupyter 笔记本的文件夹也包含 Project.toml 和 Manifest.toml 文件,那么由它们定义的环境将自动激活。
运行本书中的代码示例
伴随这本书的 GitHub 仓库 (github.com/bkamins/Julia ForDataAnalysis) 包含 Project.toml 和 Manifest.toml 文件,这些文件指定了我展示的所有代码示例中使用的项目环境。因此,我建议当您测试这本书中的任何代码示例时,请确保在激活此项目环境的情况下运行它们。这将确保您不需要手动安装任何包,并且您使用的包的版本与我创建书籍时使用的版本相匹配。
A.3.3 安装包可能遇到的问题
一些 Julia 包在使用之前需要外部依赖。如果您在 Linux 上工作,这个问题主要会遇到。如果是这种情况,相应 Julia 包的文档通常会提供所有必需的安装说明。
例如,如果我们考虑这本书中使用的包,如果您想使用 Plots.jl 进行绘图,可能需要一些配置。默认情况下,此包使用 GR 框架 (gr-framework.org) 来显示创建的图表。在 Linux 上,要使用此框架,您需要安装几个依赖项,如 gr-framework.org/julia.html 中所述。例如,如果您使用 Ubuntu,请使用以下命令确保所有依赖项都可用:
apt install libxt6 libxrender1 libxext6 libgl1-mesa-glx libqt5widgets5
使用依赖于外部二进制依赖项的包时可能遇到的另一个潜在问题是,您可能需要手动调用它们的构建脚本。例如,当依赖项的二进制文件发生变化时,有时需要这样做。在这种情况下,在包管理器模式下调用构建命令(提示符应该是 pkg>)。这将调用所有具有构建脚本的包的构建脚本。
A.3.4 管理包
在您激活并实例化项目环境后,您可以开始编写使用给定环境提供的包的 Julia 程序。然而,您有时会想要管理可用的包。最常见的包管理操作是列出可用的包、添加包、删除包和更新包。我将向您展示如何执行这些操作。在以下示例中,我将在一个空文件夹 D:\Example 中工作,以确保我们不会意外修改您已有的项目环境。
首先,创建 D:\Example 文件夹(或任何空文件夹),并在该文件夹中启动您的终端。接下来,使用 julia 命令启动 Julia,并使用 pwd 函数确保您在适当的文件夹中:
$ julia
_
_ _ _(_)_ | Documentation: https://docs.julialang.org
(_) | (_) (_) |
_ _ _| |_ __ _ | Type "?" for help, "]?" for Pkg help.
| | | | | | |/ _` | |
| | |_| | | | (_| | | Version 1.7.2 (2022-02-06)
_/ |\__'_|_|_|\__'_| | Official https://julialang.org/ release
|__/ |
julia> pwd()
"D:\\Example"
现在通过按下 ] 键切换到包管理器模式,并激活当前工作目录中的环境:
(@v1.7) pkg> activate .
Activating new project at `D:\Example`
(Example) pkg>
这是一个新的空环境。我们可以通过运行状态命令来检查它:
(Example) pkg> status
Status `D:\Example\Project.toml` (empty project)
现在,我们通过使用 add BenchmarkTools 命令将 BenchmarkTools.jl 包添加到这个环境中:
(Example) pkg> add BenchmarkTools
Updating registry at `D:\.julia\registries\General`
Updating git-repo `https://github.com/JuliaRegistries/General.git`
Resolving package versions...
Updating `D:\Example\Project.toml`
[6e4b80f9] + BenchmarkTools v1.3.1
Updating `D:\Example\Manifest.toml`
[6e4b80f9] + BenchmarkTools v1.3.1
[682c06a0] + JSON v0.21.3
[69de0a69] + Parsers v2.2.3
[56f22d72] + Artifacts
[ade2ca70] + Dates
[8f399da3] + Libdl
[37e2e46d] + LinearAlgebra
[56ddb016] + Logging
[a63ad114] + Mmap
[de0858da] + Printf
[9abbd945] + Profile
[9a3f8284] + Random
[ea8e919c] + SHA
[9e88b42a] + Serialization
[2f01184e] + SparseArrays
[10745b16] + Statistics
[cf7118a7] + UUIDs
[4ec0a83e] + Unicode
[e66e0078] + CompilerSupportLibraries_jll
[4536629a] + OpenBLAS_jll
[8e850b90] + libblastrampoline_jll
在此过程中,我们得到的信息表明 BenchmarkTools 条目被添加到 Project.toml 文件中,并且一个包列表被添加到 Manifest.toml 文件中。加号 (+) 字符表示添加了一个包。回想一下,Manifest.toml 包含了我们项目的直接依赖项以及其他需要正确设置项目环境的包。
让我们再次检查我们的项目环境状态:
(Example) pkg> status
Status `D:\Example\Project.toml`
[6e4b80f9] BenchmarkTools v1.3.1
我们看到现在我们已经安装了 BenchmarkTools.jl 包,版本为 1.3.1。
经过一段时间后,BenchmarkTools.jl 可能会有新的版本发布。你可以通过使用更新命令来更新已安装包的版本到最新发布版。在我们的例子中,因为我们刚刚安装了 BenchmarkTools.jl 包,所以该命令不会做出任何更改:
(Example) pkg> update
Updating registry at `D: \.julia\registries\General`
Updating git-repo `https://github.com/JuliaRegistries/General.git`
No Changes to `D:\Example\Project.toml`
No Changes to `D:\Example\Manifest.toml`
最后,如果你想从你的项目环境中移除一个包,请使用移除命令:
(Example) pkg> remove BenchmarkTools
Updating `D:\Example\Project.toml`
[6e4b80f9] - BenchmarkTools v1.3.1
Updating `D:\Example\Manifest.toml`
[6e4b80f9] - BenchmarkTools v1.3.1
[682c06a0] - JSON v0.21.3
[69de0a69] - Parsers v2.2.3
[56f22d72] - Artifacts
[ade2ca70] - Dates
[8f399da3] - Libdl
[37e2e46d] - LinearAlgebra
[56ddb016] - Logging
[a63ad114] - Mmap
[de0858da] - Printf
[9abbd945] - Profile
[9a3f8284] - Random
[ea8e919c] - SHA
[9e88b42a] - Serialization
[2f01184e] - SparseArrays
[10745b16] - Statistics
[cf7118a7] - UUIDs
[4ec0a83e] - Unicode
[e66e0078] - CompilerSupportLibraries_jll
[4536629a] - OpenBLAS_jll
[8e850b90] - libblastrampoline_jll
(Example) pkg> status
Status `D:\Example\Project.toml` (empty project)
包管理器不仅从 Project.toml 中移除了 BenchmarkTools 包,还从 Manifest.toml 中移除了所有不必要的包。减号 (-) 字符表示移除一个包。
A.3.5 设置与 Python 的集成
PyCall.jl 包提供了 Julia 与 Python 的集成。我们在第五章中讨论了该包的使用。在这里,我讨论了在 Windows 和 Mac 系统上安装该包的过程。
在我们刚刚创建的 Example 项目环境中,添加 PyCall.jl 包(我裁剪了添加到 Manifest.toml 中的包列表,因为它很长):
(Example) pkg> add PyCall
Resolving package versions...
Updating `D:\Example\Project.toml`
[438e738f] + PyCall v1.93.1
Updating `D:\Example\Manifest.toml`
[8f4d0f93] + Conda v1.7.0
[682c06a0] + JSON v0.21.3
[1914dd2f] + MacroTools v0.5.9
...
[83775a58] + Zlib_jll
[8e850b90] + libblastrampoline_jll
[8e850ede] + nghttp2_jll
通常,整个配置过程应该自动完成,此时你应该能够开始使用 Python。让我们检查 PyCall.jl 包默认使用的 Python 可执行文件的路径:
julia> using PyCall
julia> PyCall.python
"C:\\Users\\user\\.julia\\conda\\3\\python.exe"
如你所见,Python 安装在 conda 目录中的 Julia 安装内部。这是因为默认情况下,在 Windows 和 Mac 系统上安装 PyCall.jl 包时,会安装一个专属于 Julia 的最小 Python 发行版(通过 Miniconda)(不在你的 PATH 中)。或者,你可以指定应该使用的另一个 Python 安装,如文档中所述(mng.bz/lRVM)。
在 GNU/Linux 系统下,情况不同,PyCall.jl 将默认使用你的 PATH 中的 python3 程序(如果有——否则使用 python)。
A.3.6 设置与 R 的集成
RCall.jl 包提供了 Julia 与 R 语言的集成。我们在第十章中讨论了这个包。我将向你展示如何安装它。
作为第一步,我建议你下载并安装 R 到你的机器上。你必须在你开始 Julia 会话之前这样做,否则在尝试安装 RCall.jl 包时你会得到错误。
Windows 用户可以在 cran.r-project.org/bin/windows/base/ 找到安装说明,macOS 用户在 cran.r-project.org/bin/macosx/。
如果你使用 Linux,安装将取决于你使用的发行版。如果你使用 Ubuntu,你可以在 mng.bz/BZAg 找到可以遵循的说明。
完成此安装后,当 RCall.jl 包被添加时,操作系统应该能够自动检测它。
在 Example 项目环境中,我们添加了 RCall.jl 包(我剪裁了添加到 Manifest.toml 中的包列表,因为它很长):
(Example) pkg> add RCall
Resolving package versions...
Installed DualNumbers ______ v0.6.7
Installed NaNMath __________ v1.0.0
Installed InverseFunctions _ v0.1.3
Installed Compat ___________ v3.42.0
Installed LogExpFunctions __ v0.3.7
Updating `D:\Example\Project.toml`
[6f49c342] + RCall v0.13.13
Updating `D:\Example\Manifest.toml`
[49dc2e85] + Calculus v0.5.1
[324d7699] + CategoricalArrays v0.10.3
[d360d2e6] + ChainRulesCore v1.13.0
...
[cf7118a7] + UUIDs
[05823500] + OpenLibm_jll
[3f19e933] + p7zip_jll
通常,RCall.jl 包应该能够自动检测你的 R 安装。你可以通过使用该包并检查 R 可执行文件的位置来验证它是否正常工作:
julia> using RCall
julia> RCall.Rhome
"C:\\Program Files\\R\\R-4.1.2"
如果你在机器上自动检测 R 安装时遇到问题,请参考文档(mng.bz/dedX)以获取更详细的说明,因为这些说明取决于你使用的操作系统。
A.4 检查与 Julia 交互的标准方式
在本节中,我将讨论用户与 Julia 交互的四种最常见方式:
-
使用终端和 Julia 可执行文件
-
使用 Visual Studio Code
-
使用 Jupyter Notebook
-
使用 Pluto Notebook
A.4.1 使用终端
终端是与 Julia 交互的最基本方式。你有两种运行 Julia 的选项。
第一种选项是通过运行 julia 可执行文件来启动一个交互会话。然后,如 A.1 节所述,你将看到 julia> 提示符,并能够交互式地执行 Julia 命令。
第二种选项是运行 Julia 脚本。如果你的 Julia 代码存储在文件中(例如,命名为 code.jl),那么通过运行 julia code.jl,你将要求 Julia 运行存储在 code.jl 中的代码,然后终止。
此外,Julia 可执行文件可以接受多个命令行选项和开关。你可以在 Julia 手册的“命令行选项”部分找到完整的列表(mng.bz/rnjZ)。
A.4.2 使用 Visual Studio Code
与 Julia 一起工作的流行选项之一是使用 Visual Studio Code。你可以在 code.visualstudio.com/ 下载此集成开发环境。
接下来,你需要安装 Julia 扩展。你可以在 mng.bz/VyRO 找到说明。该扩展提供了内置动态自动完成、内联结果、绘图面板、集成 REPL、变量视图、代码导航、调试器等功能。查看扩展的文档以了解如何使用和配置所有选项。
A.4.3 使用 Jupyter Notebook
Julia 代码可以在 Jupyter Notebook 中运行(jupyter.org)。这种组合允许你通过使用图形笔记本与 Julia 语言进行交互,该笔记本将代码、格式化文本、数学和多媒体结合在一个文档中。
要在 Jupyter Notebook 中使用 Julia,首先安装 IJulia.jl 包。接下来,在浏览器中运行 IJulia Notebook 的最简单方法是运行以下代码:
using IJulia
notebook()
对于更高级的安装选项,例如指定要使用的特定 Jupyter 安装,请参阅 IJulia.jl 包的文档 (julialang.github.io/IJulia.jl/stable/)。
A.4.4 使用 Pluto 笔记本
Pluto 笔记本允许您将代码和文本结合起来,就像 Jupyter 笔记本一样。区别在于 Pluto 笔记本是响应式的:如果您更改一个变量,Pluto 会自动重新运行引用该变量的单元格。单元格可以在笔记本中任意顺序放置,因为它会自动识别它们之间的依赖关系。此外,Pluto 笔记本了解笔记本中正在使用哪些包。您无需自己安装包,因为 Pluto 笔记本会自动为您管理项目环境。
您可以在包网站上了解更多关于 Pluto 笔记本功能以及如何使用它们的信息 (github.com/fonsp/Pluto.jl)。
附录 B 练习题解答
练习 3.1
创建一个 x 变量,它是从 1 到 10⁶ 的值范围。现在,使用 collect 函数,创建一个包含与 x 范围相同值的 y 向量。使用@btime 宏,通过使用 sort 函数检查排序 x 和 y 的时间。最后,使用@edit 宏,检查当你排序 x 范围时将被调用的 sort 函数的实现。
解决方案
julia> using BenchmarkTools
julia> x = 1:10⁶;
julia> y = collect(x);
julia> @btime sort($x);
1.100 ns (0 allocations: 0 bytes)
julia> @btime sort($y);
7.107 ms (2 allocations: 7.63 MiB)
julia> @edit sort(x)
注意到对 x 范围的排序比排序 y 向量要快得多。如果你有一个正确配置的 Julia 环境(参见附录 A 获取说明),调用@edit sort(x)应该带你到编辑器并显示以下方法定义:
sort(r::AbstractUnitRange) = r
练习 4.1
使用视图(无论是视图函数还是@view 宏)重写表达式[cor(aq[:, i], aq[:, i+1]) for i in 1:2:7]。通过使用 BenchmarkTools.jl 包中的@benchmark 宏来比较两种方法的性能。
解决方案
julia> using Statistics
julia> using BenchmarkTools
julia> aq = [10.0 8.04 10.0 9.14 10.0 7.46 8.0 6.58
8.0 6.95 8.0 8.14 8.0 6.77 8.0 5.76
13.0 7.58 13.0 8.74 13.0 12.74 8.0 7.71
9.0 8.81 9.0 8.77 9.0 7.11 8.0 8.84
11.0 8.33 11.0 9.26 11.0 7.81 8.0 8.47
14.0 9.96 14.0 8.1 14.0 8.84 8.0 7.04
6.0 7.24 6.0 6.13 6.0 6.08 8.0 5.25
4.0 4.26 4.0 3.1 4.0 5.39 19.0 12.50
12.0 10.84 12.0 9.13 12.0 8.15 8.0 5.56
7.0 4.82 7.0 7.26 7.0 6.42 8.0 7.91
5.0 5.68 5.0 4.74 5.0 5.73 8.0 6.89];
我们现在运行第一个基准测试:
julia> @benchmark [cor($aq[:, i], $aq[:, i+1]) for i in 1:2:7]
它会产生以下输出:

现在运行第二个基准测试:
julia> @benchmark [cor(view($aq, :, i), view($aq, :, i+1)) for i in 1:2:7]
这次执行更快,如以下输出所示:

基准测试的结果显示,使用视图几乎将代码的执行时间减半。
如果你想要使用@view 宏,代码将是这样的:
[cor(@view(aq[:, i]), @view(aq[:, i+1])) for i in 1:2:7]
在示例代码中,注意我们在 aq 变量前使用了$前缀,以正确传递给@benchmark 宏(参见第二章解释此规则)。
练习 4.2
重新编写解决 Sicherman 谜题的代码,将处理逻辑封装在函数中。创建一个函数,dice_distribution,它接受两个作为其参数的骰子值,并生成一个包含可能组合值的总和分布的字典。接下来,编写另一个函数,test_dice,在其中创建 all_dice 变量和 two_standard 变量,然后运行主循环,比较 all_dice 向量中所有骰子的分布与 two_standard 分布。
解决方案
function dice_distribution(dice1, dice2)
distribution = Dict{Int, Int}()
for i in dice1
for j in dice2
s = i + j
if haskey(distribution, s)
distribution[s] += 1
else
distribution[s] = 1
end
end
end
return distribution
end
function test_dice()
all_dice = [[1, x2, x3, x4, x5, x6]
for x2 in 2:11
for x3 in x2:11
for x4 in x3:11
for x5 in x4:11
for x6 in x5:11]
two_standard = dice_distribution(1:6, 1:6)
for d1 in all_dice, d2 in all_dice
test = dice_distribution(d1, d2)
if test == two_standard
println(d1, " ", d2)
end
end
end
现在,你可以通过运行以下命令来测试解决方案:
julia> test_dice()
[1, 2, 2, 3, 3, 4] [1, 3, 4, 5, 6, 8]
[1, 2, 3, 4, 5, 6] [1, 2, 3, 4, 5, 6]
[1, 3, 4, 5, 6, 8] [1, 2, 2, 3, 3, 4]
练习 4.3
使用在列表 4.2 中定义的命名元组的数据重现图 4.6。
解决方案
plot(scatter(data.set1.x, data.set1.y; legend=false),
scatter(data.set2.x, data.set2.y; legend=false),
scatter(data.set3.x, data.set3.y; legend=false),
scatter(data.set4.x, data.set4.y; legend=false))
此代码重现了图 4.6。
练习 5.1
解析函数可以用来将字符串转换为数字。例如,如果你想将一个字符串解析为整数,写 parse(Int, "10")以获取整数 10。假设你被给了一个包含字符串["1", "2", "3"]的向量。你的任务是创建一个包含给定向量中字符串的整数的向量。
解决方案
julia> parse.(Int, ["1", "2", "3"])
3-element Vector{Int64}:
1
2
3
练习 5.2
重复第 5.3 节中展示的分析,但在为 1 号和 2 号集群创建数据时,分别加上和减去 0.4。这将减少五维空间中两个集群之间的距离。检查这是否会减少由 t-SNE 生成的二维空间中的距离。
解答
julia> Random.seed!(1234);
julia> data5bis = [randn(100, 5) .- 0.4; randn(100, 5) .+ 0.4];
julia> tsne = manifold.TSNE(n_components=2, init="random",
learning_rate="auto", random_state=1234);
julia> data2bis = tsne.fit_transform(data5bis);
julia> scatter(data2bis[:, 1], data2bis[:, 2];
color=[fill("black", 100); fill("gold", 100)],
legend=false)
图 B.1 显示了结果。我们可以看到,与图 5.3 相比,集群的重叠更多。

图 B.1 在这个 t-SNE 嵌入的结果中,表示为不同填充颜色的点的集群是重叠的。
练习 6.1
使用 years 变量创建按年份划分的电影数量图表。
解答
julia> years_table = freqtable(years)
93-element Named Vector{Int64}
Dim1 │
──────+────
1913 │ 1
1916 │ 1
1917 │ 1
: :
2011 │ 322
2012 │ 409
2013 │ 85
julia> plot(names(years_table, 1), years_table; legend=false,
xlabel="year", ylabel="# of movies")
您的结果应该看起来像图 B.2 中的图表,其中我们看到每年电影数量的急剧增加,除了最后一年,因为很可能在整个期间没有收集到数据。

图 B.2 在这个每年电影数量的图表中,观察值在多年内急剧增加。
练习 6.2
使用列表 6.7 中的 s1 向量,创建包含与 s1 向量中相同字符串的符号的 s3 向量。然后,基准测试您能够多快地对 s3 向量进行排序。最后,基准测试您能够多快地使用 unique 函数对 s1、s2 和 s3 向量进行去重。
解答
julia> s3 = Symbol.(s1)
1000000-element Vector{Symbol}:
:KYD
:tLO
:xnU
:
:Tt6
Symbol("19y")
:GQ7
julia> @btime sort($s3);
193.934 ms (4 allocations: 11.44 MiB)
将 s3 绑定到 Vector{Symbol}的排序比将 s1 绑定到 Vector{String}的排序快一点,但比将 s2 绑定到 Vector{String3}的排序慢。
现在我们测试去重:
julia> @btime unique($s1);
122.145 ms (49 allocations: 10.46 MiB)
julia> @btime unique($s2);
29.882 ms (48 allocations: 6.16 MiB)
julia> @btime unique($s3);
25.168 ms (49 allocations: 10.46 MiB)
De-duplicating Vector{String}是最慢的,而对于 Vector{Symbol}和 Vector{String3},性能相似。处理 Symbol 值很快,因为比较符号的相等性是高效的,如第 6.1 节所述。
练习 7.1
给定向量 v = ["1", "2", missing, "4"],将其解析为将字符串转换为数字,并将缺失值保留为缺失值。
解答
我将展示三种实现预期结果的方法。第一种使用列表推导,第二种使用 map 函数,最后使用 passmissing 函数和广播:
julia> v = ["1", "2", missing, "4"]
4-element Vector{Union{Missing, String}}:
"1"
"2"
missing
"4"
julia> [ismissing(x) ? missing : parse(Int, x) for x in v]
4-element Vector{Union{Missing, Int64}}:
1
2
missing
4
julia> map(v) do x
if ismissing(x)
return missing
else
return parse(Int, x)
end
end
4-element Vector{Union{Missing, Int64}}:
1
2
missing
4
julia> using Missings
julia> passmissing(parse).(Int, v)
4-element Vector{Union{Missing, Int64}}:
1
2
missing
4
练习 7.2
创建一个包含 2021 年每月第一天的向量。
解答
我将向您展示两种实现预期结果的方法。在第二种方法中,我们使用一个范围,因此我使用 collect 函数向您展示结果确实如预期:
julia> using Dates
julia> Date.(2021, 1:12, 1)
12-element Vector{Date}:
2021-01-01
2021-02-01
2021-03-01
2021-04-01
2021-05-01
2021-06-01
2021-07-01
2021-08-01
2021-09-01
2021-10-01
2021-11-01
2021-12-01
julia> Date(2021, 1, 1):Month(1):Date(2021, 12, 1)
Date("2021-01-01"):Month(1):Date("2021-12-01")
julia> collect(Date(2021, 1, 1):Month(1):Date(2021, 12, 1))
12-element Vector{Date}:
2021-01-01
2021-02-01
2021-03-01
2021-04-01
2021-05-01
2021-06-01
2021-07-01
2021-08-01
2021-09-01
2021-10-01
2021-11-01
2021-12-01
注意,在第二种情况下,Julia 正确地计算了一个月的时间间隔,尽管不同的月份天数不同。这确实是期望的行为。
练习 7.3
NBP Web API 允许您获取一段时间内的汇率序列。例如,查询"https://api.nbp.pl/api/exchangerates/rates/a/usd/2020-06-01/2020-06-30/?format=json"返回了 2020 年 6 月有汇率的日期的汇率序列。换句话说,跳过了没有汇率的日期。您的任务是解析此查询的结果,并确认获得的结果与我们收集的 dates 和 rates 向量中的数据一致。
解决方案
julia> query2 = "https://api.nbp.pl/api/exchangerates/rates/a/usd/" *
"2020-06-01/2020-06-30/?format=json";
julia> response2 = HTTP.get(query2);
julia> json2 = JSON3.read(response2.body)
JSON3.Object{Vector{UInt8}, Vector{UInt64}} with 4 entries:
:table => "A"
:currency => "dolar amerykański"
:code => "USD"
:rates => JSON3.Object[{...
julia> rates2 = [x.mid for x in json2.rates]
21-element Vector{Float64}:
3.968
3.9303
3.9121
⋮
3.9697
3.9656
3.9806
julia> dates2 = [Date(x.effectiveDate) for x in json2.rates]
21-element Vector{Date}:
2020-06-01
2020-06-02
2020-06-03
⋮
2020-06-26
2020-06-29
2020-06-30
julia> has_rate = rates .!== missing
30-element BitVector:
1
1
1
⋮
0
1
1
julia> rates2 == rates[has_rate]
true
julia> dates2 == dates[has_rate]
true
在解决方案中,:rates 字段中的 json2 对象包含一系列汇率。因此,我们通过使用列表推导式将它们提取到 rates2 和 dates2 向量中。接下来,我们想要比较 rates2 和 dates2 向量与 rates 和 dates 向量在那些 rates 向量不包含缺失值的条目中进行。为此,我们创建了一个 has_rate 布尔掩码向量,通过将缺失值与 rates 向量进行 !== 比较进行广播。
练习 8.1
使用 BenchmarkTools.jl 包,通过使用 puzzles."Rating"语法来获取数据框中的列的性能进行测量。
解决方案
julia> using BenchmarkTools
julia> @btime $puzzles."Rating";
36.831 ns (0 allocations: 0 bytes)
如预期的那样,性能略低于 puzzles.Rating。
练习 9.1
在两种条件下计算 NbPlays 列的摘要统计。在第一种情况下,仅选择流行度等于 100 的谜题,在第二种情况下,选择流行度等于-100 的谜题。要计算向量的摘要统计,请使用 StatsBase.jl 包中的 summarystats 函数。
解决方案
julia> using StatsBase
julia> summarystats(puzzles[puzzles.Popularity .== 100, "NbPlays"])
Summary Stats:
Length: 148244
Missing Count: 0
Mean: 283.490280
Minimum: 0.000000
1st Quartile: 6.000000
Median: 124.000000
3rd Quartile: 396.000000
Maximum: 8899.000000
julia> summarystats(puzzles[puzzles.Popularity .== -100, "NbPlays"])
Summary Stats:
Length: 13613
Missing Count: 0
Mean: 4.337839
Minimum: 0.000000
1st Quartile: 3.000000
Median: 4.000000
3rd Quartile: 5.000000
Maximum: 35.000000
我们可以看到,那些流行度等于-100 的谜题确实被玩得很少。然而,对于那些流行度为 100 的谜题,这种关系并不那么强烈。如您从列表 8.2 中的代码产生的输出中回忆起来,整个数据集的播放次数平均值约为 891,中位数约为 246。因此,100 流行度的谜题平均播放次数略少,但关系并不那么强烈。其中一些谜题似乎只是非常好。
练习 9.2
确保存储在 rating_mapping 字典中的值加起来代表我们良好数据框的所有行索引。为此,检查这些向量的长度之和是否等于良好数据框中的行数。
解决方案
julia> sum(length, values(rating_mapping))
513357
julia> nrow(good)
513357
使用带有其第一个参数为转换函数的 sum 函数的解释在第四章中。
练习 9.3
检查在 loess 函数中更改 span 关键字参数值的影响。默认情况下,此参数的值为 0.75。将其设置为 0.25,并在图 9.4 所示的图中添加另一条预测线。使线条为黄色,宽度为 5。
解决方案
julia> model2 = loess(ratings, mean_popularities; span=0.25);
julia> popularity_predict2 = predict(model2, ratings_predict);
julia> plot!(ratings_predict, popularity_predict2;
width=5, color="yellow");
图 B.3 显示了结果。请注意,通过应用较少的平滑处理,曲线与数据的拟合略好。与图 9.4 的预测相比,它在边缘具有较低的偏差,评分为约 1500(原始预测略有向上偏差),在极值处评分为约 1750(原始预测略有向下偏差)。

图 B.3 此显示网络和机器学习邻居数量与机器学习开发者比例之间关系的图表是在没有抖动的情况下创建的。
练习 10.1
比较创建包含单个随机向量(一百万个元素)的数据框时,是否复制源向量对性能的影响。你可以通过使用 rand(10⁶)命令生成此向量。
解决方案
julia> using BenchmarkTools
julia> x = rand(10⁶);
julia> @btime DataFrame(x=$x);
1.010 ms (22 allocations: 7.63 MiB)
julia> @btime DataFrame(x=$x; copycols=false);
827.941 ns (21 allocations: 1.50 KiB)
使用 copycols=false 可以减少内存分配并加快代码执行速度。
练习 10.2
检查两个数据框 vcat 的结果,df1=DataFrame(a=1, b=2)和 df2=DataFrame(b=2, a=1)。接下来,验证如果我们也传递 cols=:orderequal 关键字参数时的操作结果。
解决方案
julia> df1 = DataFrame(a=1,b=2)
1×2 DataFrame
Row │ a b
│ Int64 Int64
─────┼──────────────
1 │ 1 2
julia> df2 = DataFrame(b=3, a=4)
1×2 DataFrame
Row │ b a
│ Int64 Int64
─────┼──────────────
1 │ 3 4
julia> vcat(df1, df2)
2×2 DataFrame
Row │ a b
│ Int64 Int64
─────┼──────────────
1 │ 1 2
2 │ 4 3
julia> vcat(df1, df2, cols=:orderequal)
ERROR: ArgumentError: when `cols=:orderequal` all data frames need
to have the same column names and be in the same order
练习 10.3
将列表 10.7 中的代码修改为,如果随机游走再次访问相同点,则只执行两步验证。验证在这种情况下,我们没有重复访问相同点的概率大约为 7.5%。
解决方案
julia> function walk_unique_2ahead()
walk = DataFrame(x=0, y=0)
for _ in 1:10
current = walk[end, :]
push!(walk, sim_step(current))
end
return all(walk[i, :] != walk[i+2, :] for i in 1:9)
end
walk_unique_2ahead (generic function with 1 method)
julia> Random.seed!(2);
julia> proptable([walk_unique_2ahead() for _ in 1:10⁵])
2-element Named Vector{Float64}
Dim1 │
──────┼────────
false │ 0.92472
true │ 0.07528
与列表 10.7 相比,此代码的差异在于这次,我们检查的条件是 all(walk[i, :] != walk[i+2, :] for i in 1:9)。如第 10.2 节所述,我们检查在实例 1 和 3、2 和 4、...、9 和 11 中是否访问了不同的点。
结果大约为 7.5%,正如预期的那样。
练习 11.1
测量创建一个包含 10,000 列且只有 1s 的单行数据框所需的时间。使用由 ones(1, 10_000)创建的矩阵作为源,并自动生成列名。接下来,测量从该数据框创建向量 NamedTuple 所需的时间。
解决方案
julia> @time wide = DataFrame(ones(1, 10_000), :auto);
0.092228 seconds (168.57 k allocations: 9.453 MiB,
94.13% compilation time)
julia> @time wide = DataFrame(ones(1, 10_000), :auto);
0.006999 seconds (39.53 k allocations: 2.508 MiB)
julia> @time Tables.columntable(wide);
18.517356 seconds (1.70 M allocations: 65.616 MiB, 0.08% gc time,
99.60% compilation time)
julia> @time Tables.columntable(wide);
0.002036 seconds (25 allocations: 938.750 KiB)
创建非常宽的数据框对象非常快,即使在第一次运行时也是如此。另一方面,创建宽 NamedTuple 对象会带来非常高的编译成本。Tables.columntable(wide)的第二次运行很快,因为 Julia 缓存了用于所需列名和类型的函数的编译结果。
练习 11.2
使用 gdf_city 分组数据框,通过使用 Statistics 模块中的 mean 函数计算每个城市的平均温度。将结果存储为一个字典,其中键是城市名称,值是对应的平均温度。将你的结果与以下调用的输出进行比较:combine(gdf_city, :rainfall => mean)。(我们将在第十二章和第十三章中讨论此类表达式的确切语法。)
解决方案
julia> using Statistics
julia> Dict(key.city => mean(df.rainfall) for (key, df) in pairs(gdf_city))
Dict{String7, Float64} with 2 entries:
"Ełk" => 2.275
"Olecko" => 2.48333
julia> combine(gdf_city, :rainfall => mean)
2×2 DataFrame
Row │ city rainfall_mean
│ String7 Float64
─────┼────────────────────────
1 │ Olecko 2.48333
2 │ Ełk 2.275
练习 12.1
使用 complete_graph(37700) 调用,在 37,700 个节点上创建一个完整图(我们在 gh 图中拥有的节点数)。但请注意:如果你的机器上少于 32 GB RAM,请使用更小的图大小,因为这个练习是内存密集型的。接下来,使用 Base.summarysize 函数检查这个图占用多少内存。最后,使用 @time 函数检查 deg_class 函数在这个图上完成所需的时间,使用 classes_df.ml_target 向量作为开发者类型的向量。
解决方案
julia> cg = complete_graph(37700)
{37700, 710626150} undirected simple Int64 graph
julia> Base.summarysize(cg)
11371828056
julia> @time deg_class(cg, classes_df.ml_target);
7.114192 seconds (5 allocations: 589.250 KiB)
与我们在第十二章中的讨论一致,我们看到,在 37,700 个节点上的完整图有 710,626,150 条边。创建该图需要大约 11 GB 的 RAM。在图上执行 deg_class 函数大约需要 7 秒钟。
练习 12.2。
检查如果从图中移除抖动,图 12.6 的绘制效果会如何。
解决方案
scatter(log1p.(agg_df.deg_ml),
log1p.(agg_df.deg_web);
zcolor=agg_df.web_mean,
xlabel="degree ml", ylabel="degree web",
markersize=2, markerstrokewidth=0, markeralpha=0.8,
legend=:topleft, labels = "fraction web",
xticks=gen_ticks(maximum(classes_df.deg_ml)),
yticks=gen_ticks(maximum(classes_df.deg_web)))
此代码生成了图 B.4 的绘制。

图 B.4:网络和机器学习邻居数量与机器学习开发者比例之间的关系图。该图在无抖动的情况下创建。
如果你比较图 B.4 和 12.6,你会看到,确实,图 B.4 中绘制的点有很多重叠,这可能导致一些暗点被忽视,因为它们会被许多亮点覆盖。
练习 12.3
使用 probit 模型而不是 logit 模型来预测 ml_target 变量。使用 glm 函数的 ProbitLink() 参数。
解决方案
julia> glm(@formula(ml_target~log1p(deg_ml)+log1p(deg_web)),
classes_df, Binomial(), ProbitLink())
StatsModels.TableRegressionModel{GeneralizedLinearModel{
GLM.GlmResp{Vector{Float64}, Binomial{Float64}, ProbitLink},
GLM.DensePredChol{Float64, LinearAlgebra.Cholesky{Float64,
Matrix{Float64}}}}, Matrix{Float64}}
ml_target ~ 1 + :(log1p(deg_ml)) + :(log1p(deg_web))
Coefficients:
───────────────────────────────────────────────────────────────────────────
Coef. Std. Error z Pr(>|z|) Lower 95% Upper 95%
───────────────────────────────────────────────────────────────────────────
(Intercept) 0.142686 0.0161981 8.81 <1e-17 0.110939 0.174434
log1p(deg_ml) 1.02324 0.0119645 85.52 <1e-99 0.999791 1.04669
log1p(deg_web) -0.91654 0.0108211 -84.70 <1e-99 -0.937749 -0.895331
───────────────────────────────────────────────────────────────────────────
练习 12.4
创建一个空数据框。向其中添加一个名为 a 的列,存储值 1、2 和 3,而不进行复制。接下来,在数据框中创建另一个名为 b 的列,该列与列 a 相同的向量(不进行复制)。检查列 a 和 b 存储的向量是否相同。在数据框中存储两个相同的列是不安全的,因此,在列 b 中存储其副本。现在检查列 a 和 b 存储相同的数据但对象不同。就地更新列 a 的前两个元素为 10。
解决方案
julia> df = DataFrame()
0×0 DataFrame
julia> df.a = [1, 2, 3]
3-element Vector{Int64}:
1
2
3
julia> df.b = df.a
3-element Vector{Int64}:
1
2
3
julia> df.b === df.a
true
julia> df.b = df[:, "b"]
3-element Vector{Int64}:
1
2
3
julia> df.b === df.a
false
julia> df.b == df.a
true
julia> df[1:2, "a"] .= 10
2-element view(::Vector{Int64}, 1:2) with eltype Int64:
10
10
julia> df
3×2 DataFrame
Row │ a b
│ Int64 Int64
─────┼──────────────
1 │ 10 1
2 │ 10 2
3 │ 3 3
在这些操作中,最棘手的是 df.b = df[:, "b"]。我们将复制的值 df[:, "b"] 赋值给现有的列 b。或者,我们也可以写成 df.b = copy(df.b)。
练习 13.1
使用 DataFramesMeta.jl 的 @rselect 宏重写列表 13.6 中的代码。
解决方案
@rselect(owensboro,
:arrest = :arrest_made,
:day = dayofweek(:date),
:type,
:v1 = contains(:violation, agg_violation.v[1]),
:v2 = contains(:violation, agg_violation.v[2]),
:v3 = contains(:violation, agg_violation.v[3]),
:v4 = contains(:violation, agg_violation.v[4]))
注意,使用 @rselect,我们可以更容易地指定简单的转换,但最后四个转换,我们在列表 13.6 中程序化生成的,必须明确写出。
练习 13.2
编写一个选择操作创建 owensboro2 数据框,该数据框立即包含 dayname 列(无需执行连接)。
解决方案
select(owensboro,
:arrest_made => :arrest,
:date => ByRow(dayofweek) => :day,
:type,
[:violation =>
ByRow(x -> contains(x, agg_violation.v[i])) =>
"v$i" for i in 1:4],
:date => ByRow(dayname) => :dayname)
只需将 dayname 函数应用于 :date 列即可获得所需的结果。不过,请注意,在这种情况下,获得的列不是分类的,因此我们稍后需要使用分类函数将其转换为分类列。
练习 13.3
为了练习本节学到的操作,准备以下分析。首先,计算每天被捕的概率。其次,再次计算被捕的概率,但这次按 dayname 和 type 列计算,并以宽表形式呈现结果,其中 dayname 级别是行,type 值形成列。
解决方案
julia> @chain owensboro2 begin
groupby(:dayname, sort=true)
combine(:arrest => mean)
end
7×2 DataFrame
Row │ dayname arrest_mean
│ Cat... Float64
─────┼────────────────────────
1 │ Monday 0.0825991
2 │ Tuesday 0.0928433
3 │ Wednesday 0.0780201
4 │ Thursday 0.0834846
5 │ Friday 0.112174
6 │ Saturday 0.165485
7 │ Sunday 0.258114
注意到,通常情况下,被捕的最高概率出现在周末:
julia> @chain owensboro2 begin
groupby([:dayname, :type], sort=true)
combine(:arrest => mean)
unstack(:dayname, :type, :arrest_mean)
end
7×3 DataFrame
Row │ dayname pedestrian vehicular
│ Cat... Float64? Float64?
─────┼──────────────────────────────────
1 │ Monday 0.827586 0.0580205
2 │ Tuesday 0.611111 0.0741483
3 │ Wednesday 0.568182 0.0592334
4 │ Thursday 0.568182 0.063327
5 │ Friday 0.596491 0.0869167
6 │ Saturday 0.638889 0.144444
7 │ Sunday 0.592593 0.243548
如果类型是行人,被捕的概率会高得多。
练习 13.4
通过(a)数据帧索引语法和(b)groupby 函数创建训练和测试数据帧。
解决方案
julia> train2 = owensboro2[owensboro2.train, :]
4832×8 DataFrame
Row │ arrest type v1 v2 v3 v4 dayname train
│ Bool String15 Bool Bool Bool Bool Cat... Bool
──────┼──────────────────────────────────────────────────────────────────
1 │ true pedestrian false false false false Thursday true
2 │ false vehicular false true false false Sunday true
: │ : : : : : : : :
4831 │ false vehicular true true false true Wednesday true
4832 │ false vehicular false false false true Wednesday true
4828 rows omitted
julia> test2 = owensboro2[.!owensboro2.train, :]
2047×8 DataFrame
Row │ arrest type v1 v2 v3 v4 dayname train
│ Bool String15 Bool Bool Bool Bool Cat... Bool
──────┼──────────────────────────────────────────────────────────────────
1 │ true vehicular false false false false Tuesday false
2 │ true vehicular false false false false Sunday false
: │ : : : : : : : :
2046 │ false vehicular false false true false Friday false
2047 │ false vehicular false false false false Wednesday false
2043 rows omitted
julia> test3, train3 = groupby(owensboro2, :train, sort=true)
GroupedDataFrame with 2 groups based on key: train
First Group (2047 rows): train = false
Row │ arrest type v1 v2 v3 v4 dayname train
│ Bool String15 Bool Bool Bool Bool Cat... Bool
──────┼──────────────────────────────────────────────────────────────────
1 │ true vehicular false false false false Tuesday false
2 │ true vehicular false false false false Sunday false
3 │ false vehicular false false false false Tuesday false
: │ : : : : : : : :
2046 │ false vehicular false false true false Friday false
2047 │ false vehicular false false false false Wednesday false
2042 rows omitted
⋮
Last Group (4832 rows): train = true
Row │ arrest type v1 v2 v3 v4 dayname train
│ Bool String15 Bool Bool Bool Bool Cat... Bool
──────┼──────────────────────────────────────────────────────────────────
1 │ true pedestrian false false false false Thursday true
2 │ false vehicular false true false false Sunday true
3 │ true vehicular false false false false Sunday true
: │ : : : : : : : :
4831 │ false vehicular true true false true Wednesday true
4832 │ false vehicular false false false true Wednesday true
4827 rows omitted
在使用 groupby 函数的解决方案中,我们使用 sort=true 非常重要。这确保了组按分组列排序,因此 false 键在第一个组中,true 键在最后一个组中。此外,在这种情况下,train3 和 test3 数据帧具有 SubDataFrame 类型,因此它们是原始 owensboro2 数据帧的视图。
练习 14.1
使用 @time 宏,比较使用 <(0) 和 x -> x < 0 函数计算 -10⁶:10⁶ 范围内值平均值的耗时。还要检查预先定义 lt0(x) = x < 0 函数时的计时。运行每个操作三次。
解决方案
julia> @time mean(x -> x < 0, -10⁶:10⁶)
0.058563 seconds (124.09 k allocations: 6.868 MiB, 100.84% compilation time)
0.499999750000125
julia> @time mean(x -> x < 0, -10⁶:10⁶)
0.058623 seconds (123.13 k allocations: 6.808 MiB, 99.25% compilation time)
0.499999750000125
julia> @time mean(x -> x < 0, -10⁶:10⁶)
0.059394 seconds (123.13 k allocations: 6.808 MiB, 99.22% compilation time)
0.499999750000125
julia> @time mean(<(0), -10⁶:10⁶)
0.000515 seconds
0.499999750000125
julia> @time mean(<(0), -10⁶:10⁶)
0.000608 seconds
0.499999750000125
julia> @time mean(<(0), -10⁶:10⁶)
0.000523 seconds
0.499999750000125
如您在 @time 宏的结果中所见,使用 <(0) 的代码更快,因为它不需要每次都编译,而使用 x -> x < 0 的代码则需要。
这种差异在脚本中可能并不重要,因为在脚本中通常只编译一次。但当你以交互式方式与 Julia 一起工作时,它最为相关,在这种情况下,你通常会在全局范围内手动重复相同的操作多次。
解决此问题的另一种方法是定义一个命名函数:
julia> lt0(x) = x < 0
lt0 (generic function with 1 method)
julia> @time mean(lt0, -10⁶:10⁶)
0.000433 seconds (4 allocations: 112 bytes)
0.499999750000125
julia> @time mean(lt0, -10⁶:10⁶)
0.000420 seconds (4 allocations: 112 bytes)
0.499999750000125
julia> @time mean(lt0, -10⁶:10⁶)
0.000400 seconds (4 allocations: 112 bytes)
0.499999750000125
然而,在交互式会话中,用户通常更喜欢在行内定义匿名函数,而不是预先定义它们为命名函数。
练习 14.2
创建一个接受包含单个元素 n 的 JSON 有效负载的 Web 服务,n 是一个整数。它应该以 JSON 格式返回使用 rand 函数生成的 n 个随机数的向量。如果传递的请求不正确,应生成 400 Bad Request 响应。在您的本地计算机上运行此 Web 服务器并测试它是否按预期工作。
解决方案
在解决方案中,我们现在有了服务器和客户端部分。首先,启动一个将作为服务器的 Julia 会话,并在其中运行以下代码:
using Genie
Genie.config.run_as_server = true
Genie.Router.route("/", method=POST) do
message = Genie.Requests.jsonpayload()
return try
n = message["n"]
Genie.Renderer.Json.json(rand(n))
catch
Genie.Responses.setstatus(400)
end
end
Genie.Server.up()
现在,启动另一个 Julia 会话,并测试我们创建的 Web 服务:
julia> using HTTP
julia> using JSON3
julia> req = HTTP.post("http://127.0.0.1:8000",
["Content-Type" => "application/json"],
JSON3.write((n=3,)))
HTTP.Messages.Response:
"""
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Server: Genie/Julia/1.7.2
Transfer-Encoding: chunked
[0.5328896673008208,0.832033459458785,0.4955600307532585]"""
julia> JSON3.read(req.body)
3-element JSON3.Array{Float64, Vector{UInt8}, Vector{UInt64}}:
0.5328896673008208
0.832033459458785
0.4955600307532585
julia> HTTP.post("http://127.0.0.1:8000",
["Content-Type" => "application/json"],
JSON3.write((x=3,)))
ERROR: HTTP.ExceptionRequest.StatusError(400, "POST", "/", HTTP.Messages.Response:
"""
HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8
Server: Genie/Julia/1.7.2
Transfer-Encoding: chunked
""")
在第一次调用中,我们传递一个正确的请求,并获得一个包含三个随机数的数组(您的数字可能不同)。在第二个示例中,请求格式不正确,因为我们没有传递 n,而是传递了 x。在这种情况下,服务器返回 400 Bad Request 响应。
附录 C:数据科学用的 Julia 包
阅读这本书后,你将拥有使用 Julia 进行数据分析的坚实基础。然而,对 Base Julia 和我们使用的选定包的了解可能不足以应对现实生活中的应用。因此,本附录回顾了你在数据科学工作中可能会发现有用的 Julia 包生态系统。
在数据科学项目中,除了我们在本书中关注的简单数据摄取和分析任务外,你通常还会遇到与计算规模化、处理各种数据源和格式或构建高级机器学习模型相关的挑战。所有这些主题对于数据科学家来说都是必不可少的,他们不仅想要执行简单的数据分析,还需要创建可扩展且可部署在生产环境中的复杂分析模型,在这些环境中,他们通常还需要将它们与其他软件组件集成。
本附录概述了 Julia 生态系统提供的有用功能,这些功能允许你构建复杂的数据科学解决方案。为了避免提供一个过于冗长的列表,我不得不省略了许多优秀的包。特别是,我只关注与数据科学相关的包,跳过了许多具有不同焦点(如我们在第十四章中用于创建网络服务的 Genie.jl)的包。幸运的是,你可以方便地在 JuliaHub 上探索整个 Julia 包生态系统,网址为 juliahub.com/ui/Packages。
我提供的列表可能让你感到不知所措。然而,我相信在阅读这本书后,你将获得足够的 fundamentals,能够自信地学习和在你的项目中使用这些包。为了使这些材料更有结构,我已经将其组织成四个部分,涵盖以下主题:
-
绘图
-
规模化计算任务
-
与数据库和各种数据存储格式一起工作
-
使用数据科学方法
C.1 Julia 中的绘图生态系统
Julia 提供了几个绘图生态系统。在数据科学中,绘图是基本要求之一,同时,你可能希望在不同环境中使用完全不同的工具。例如,当你想要创建一个交互式仪表板时,与当你想要准备一个应该具有出版质量的静态图表时,绘图的不同方面很重要。
在本节中,我介绍了四个选定的绘图包,并突出了它们的特点。我建议你尝试每个包,看看哪个最适合你的需求:
-
Gadfly.jl——一个 API 受 Leland Wilkinson 的书 The Grammar of Graphics(Springer,2005)和 Hadley Wickham 的 R 包 ggplot2 强烈影响的包。目前,图形语法方法指定绘图是数据科学中最受欢迎的方法之一。
-
Makie.jl—一个能够通过统一界面创建高性能、GPU 驱动、交互式可视化以及出版质量矢量图形的绘图生态系统。
-
Plots.jl—本书中使用的包;它提供了一种统一的接口,可以访问多个可用的绘图后端,如 GR、PyPlot、PGFPlotsX 和 Plotly。它捆绑了 StatsPlots.jl,其中包含许多统计绘图配方。
-
UnicodePlots.jl—一个直接在终端中生成图形的绘图库。
C.2 使用 Julia 扩展计算能力
使用 Julia,你可以轻松运行多线程代码,利用分布式计算,以及在你的程序上执行 GPU 或 Spark。如果你对这个主题没有太多经验,Robert Robey 和 Yuliana Zamora 的《并行与高性能计算》(Manning, 2021)可以作为相关概念和方法的良好入门。
这里是 Julia 生态系统中的相关包的选择:
-
AMDGPU.jl—用于编写 AMD GPU 程序的工具。
-
CUDA.jl—用于与 NVIDIA CUDA GPU 一起工作的编程接口。
-
Dagger.jl—一个调度器,允许你高效地在多个 Julia 工作进程和线程以及 GPU 上运行表示为有向无环图(DAG)的计算。
-
Distributed—一个 Julia 标准模块,提供了分布式内存计算的实现。
-
MPI.jl—对消息传递接口(MPI)的接口。
-
Spark.jl—一个允许在 Apache Spark 平台上执行 Julia 程序的包。
-
Threads—一个 Julia 标准模块,提供基本功能,允许你编写多线程代码。
-
ThreadsX.jl—第十四章中使用的包。它提供了一个与 Base Julia 函数兼容的 API,可以轻松并行化 Julia 程序。
C.3 与数据库和数据存储格式一起工作
扩展计算程序的一个重要方面是数据库和各种数据存储类型的连接器。以下是一些可用的包:
-
Arrow.jl—第八章中使用的 Apache Arrow 标准的实现。
-
AVRO.jl—Apache Avro 数据标准的纯 Julia 实现。
-
AWSS3.jl—Amazon Simple Storage Service (S3)的接口。AWS.jl 是一个相关的包,它提供了一个用于 Amazon Web Services (AWS)的接口。
-
CSV.jl—一个用于处理 CSV 文件和固定字段宽度文件的包。
-
DuckDB.jl—DuckDB SQL OLAP 数据库管理系统的一个接口。
-
HDF5.jl—用于读取和写入存储在 HDF5 文件格式中的数据的接口。
-
JSON3.jl—一个用于处理 JSON 文件的包。
-
LibPQ.jl—对 PostgreSQL libpq C 库的封装。
-
Mongoc.jl—一个 MongoDB 驱动程序。
-
MySQL.jl—MySQL 服务器的接口。
-
ODBC.jl—对 ODBC API 的接口。
-
Parquet2.jl—Parquet 表格数据二进制格式的纯 Julia 实现。
-
SQLite.jl—第八章中使用的 SQLite 库的接口。
-
RData.jl—一个用于读取 R 数据文件的包。
-
ReadStatTables.jl—一个用于从 Stata、SAS 和 SPSS 读取数据文件的包。
-
TOML—一个用于解析 TOML 文件的标准化模块。
C.4 使用数据科学方法
Julia 生态系统提供了一系列丰富的包,允许你执行高级数据科学项目。它们的功能覆盖机器学习、概率编程、优化、统计学和数值计算:
-
Agents.jl—一个用于在 Julia 中创建基于代理模型的库。
-
DifferentialEquations.jl—一个用于数值求解微分方程的套件。它是 Julia SciML 生态系统的关键包(
sciml.ai))。 -
Flux.jl—一个面向高性能生产管道的机器学习库。
-
Gen.jl、Soss.jl/Tilde.jl、Turing.jl—三个用于概率编程的替代框架(每个都有独特的功能,因此我建议你检查哪个最适合你的需求)。
-
JuliaStats—一个统计包生态系统,列在
github.com/JuliaStats。它们提供概率分布、各种单变量和多变量统计模型、假设检验和相关功能。 -
JuMP.jl—一个用于数学优化的特定领域建模语言。它支持多种问题类别,包括线性规划、整数规划、锥形规划、半定规划以及约束非线性规划。
-
Knet.jl—一个成熟的深度学习框架。
-
MLJ.jl—一个用 Julia 编写的工具箱,提供选择、调整、评估、组合和比较 160 多个机器学习模型的通用接口和元算法。
-
OnlineStats.jl—一个提供高性能单次遍历算法的统计库。
-
Optim.jl—一个用于 Julia 中的单变量和多变量非线性优化的包。
-
ReinforcementLearning.jl—一个用于 Julia 中的强化学习的包。
-
Roots.jl—一个提供寻找连续单变量实值函数根的例程的库。
此处潜在列表如此之长,以至于我不得不限制自己只列出最受欢迎的、涵盖最重要的数据科学工具和技术。特别是,我省略了列出各种提供具体机器学习模型实现的包,因为它们都可以通过 MLJ.jl 生态系统提供的通用接口访问(你可以在mng.bz/xMjY了解更多信息)。
摘要
-
Julia 有一个丰富的包生态系统,允许你实现复杂的数据科学项目。
-
Julia 有几个成熟的绘图生态系统。每个都有不同的 API 和关注领域。我建议你尝试不同的选项,选择最适合你的一个。
-
如果你进行高性能计算,Julia 提供了对多线程、分布式和 GPU 计算的全面支持。因此,你可以灵活地选择最适合扩展你计算的方式。
-
Julia 支持读取和写入多种数据格式以及连接到流行的数据库系统。您可以通过与现有的数据存储集成轻松执行您的数据分析项目。
-
Julia 中可用的数据科学工具和算法生态系统非常广泛。它们的数量如此之多,以至于在某些领域,已经创建了伞形组织来对它们进行分组。从数据科学的角度来看,重要的项目包括 JuliaStats、JuMP.jl、MLJ.jl 和 SciML。


浙公网安备 33010602011771号