Julia-编程项目-全-

Julia 编程项目(全)

原文:annas-archive.org/md5/0086c86218c52c8cc6ad2375a5d3ae02

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Julia 是一种新的编程语言,它结合了性能和生产力,有望改变科学计算以及编程本身。

Julia 选择了现有编程语言的最佳部分,提供了诸如强大的 REPL、表达性语法、Lisp 风格的元编程能力、强大的数值和科学编程库、内置的包管理器、高效的 Unicode 支持,以及可以轻松调用的 C 和 Python 函数等开箱即用的特性。

它具有与 C 语言类似的执行速度,在多核、GPU 和基于云的计算中有着出色的应用。《Julia 编程项目》 使用 Julia v1.0 的支持解释了这一切。

经过六年作为开源项目的发展,Julia 现在随着 v1.0 版本的发布,已经准备好登上舞台。

本书面向对象

数据科学家、统计学家、商业分析师和开发者,如果他们对学习如何使用 Julia 进行数据处理、数据分析以及构建应用程序感兴趣,会发现这本书很有用。本书假设读者具备基本的编程知识。

本书涵盖内容

第一章,开始使用 Julia 编程,介绍了 Julia 语言,涵盖了它是什么以及它的优势。然后,本章将指导您设置一个可工作的 Julia 环境,查看运行 Julia 的各种本地和在线选项。我们将涵盖安装、REPL 和 IDE 选项,以及通过集成包管理器扩展语言的基本知识。

第二章,创建我们的第一个 Julia 应用程序,将展示如何使用 Julia 对 Iris 数据集进行数据分析。我们查看 RDatasets 包,这是一个提供对与 R 语言一起分发的 700 个学习数据集访问权限的包。我们将加载 Iris 数据集,并使用标准的数据分析函数对其进行操作。我们还通过使用 Gadfly 等常见的可视化技术更仔细地查看数据。在这个过程中,我们将涵盖字符串和正则表达式、数字、元组、范围和数组。最后,我们将看到如何使用 CSV、Feather 和 MongoDB 持久化和(重新)加载数据。

第三章,设置维基游戏,介绍了我们的第一个功能齐全的 Julia 项目,一个伪装成流行游戏的维基百科网络爬虫。在第一轮迭代中,我们将构建一个从维基百科获取随机网页的程序。然后我们将学习如何使用 CSS 选择器解析 HTML 响应。我们将利用这一点来介绍诸如函数、元组、字典、异常和条件评估等关键概念。

第四章,构建维基游戏网络爬虫,将在前一章的基础上进行构建,我们将构建一个实现维基游戏要求的维基百科网络爬虫。

第五章,为 Wiki 游戏添加 Web UI,我们将通过添加 Web UI 来完成 Wiki 游戏的开发。我们将构建一个简单的 Web 应用程序,允许玩家开始新游戏,渲染游戏引擎选择的维基百科文章,并在链接的维基百科文章之间导航。UI 还将跟踪并显示当前游戏进度,并确定一个会话为胜利或失败。

第六章,使用 Julia 实现推荐系统,将让您承担一个更具挑战性的示例项目,并构建几个基本的推荐系统。我们将设置一个由 Julia 驱动的监督机器学习系统,并开发一些简单的电影推荐系统。

第七章,推荐系统的机器学习,将向您展示如何使用 Recommender.jl 包实现更强大的推荐系统。我们将使用样本数据集来训练我们的系统,并在学习基于模型的推荐系统时生成书籍推荐。

第八章,利用无监督学习技术,将教会您如何使用 Julia 执行无监督机器学习,即聚类。我们将通过使用旧金山商业注册来实践。我们将使用强大的 DataFrames 包和 Query.jl 来切片和切块数据集,并通过可视化获得更多见解。在这个过程中,我们将了解元编程和 Clustering.jl

第九章,处理日期、时间和时间序列,是关于日期、时间和时间序列的两章中的第一章。在这里,我们将向您介绍处理日期、时区和时间序列的基础知识。我们将使用 TimeSeries.jl 包和 Plots.jl 来分析时间序列数据,并了解 TimeArray 数据结构。

第十章,时间序列预测,我们将对欧盟失业数据进行分析并预测失业人数。您将学习如何开发预测模型、训练它并生成预测。

第十一章,创建 Julia 包,是最后一章,将指导你开发一个功能齐全的 Julia 包。我们将讨论更高级的包管理功能、单元测试、基准测试和性能技巧,为 Julia 软件添加和生成文档,以及包发布和注册。

为了充分利用本书

假设您熟悉另一种编程语言,因为本书专注于 Julia 的特定内容,而不介绍通用的编程和计算机科学概念。

您需要一个运行 Windows、macOS 或流行的 Linux 版本、并且能够安装和启动程序(命令行、集成开发环境(IDE)、编辑器等)的计算机,以及互联网连接。

下载示例代码文件

您可以从www.packt.com账户下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packt.com/support并注册,以便将文件直接通过电子邮件发送给您。

您可以通过以下步骤下载代码文件:

  1. www.packt.com登录或注册。

  2. 选择支持选项卡。

  3. 点击代码下载与勘误。

  4. 在搜索框中输入书名,并遵循屏幕上的说明。

一旦文件下载完成,请确保使用最新版本的软件解压或提取文件夹:

  • Windows 下的 WinRAR/7-Zip

  • Mac 下的 Zipeg/iZip/UnRarX

  • Linux 下的 7-Zip/PeaZip

本书代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Julia-Programming-Projects。如果代码有更新,它将在现有的 GitHub 仓库中更新。

我们还有其他来自我们丰富图书和视频目录的代码包可供在github.com/PacktPublishing/下载。查看它们!

下载彩色图像

我们还提供了一份包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:www.packtpub.com/sites/default/files/downloads/9781788292740_ColorImages.pdf

使用的约定

本书使用了多种文本约定。

CodeInText: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“难度级别已经在Gameplay模块中定义,所以不要忘记声明我们正在using Gameplay。”

代码块按照以下方式设置:

function articleinfo(content) 
  dom = articledom(content) 
  (extractcontent(dom.root), extractlinks(dom.root), extracttitle(dom.root), extractimage(dom.root)) 
end 

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

@from i in df begin 
    @where i.Parking_Tax == true 
    @select i 
    @collect DataFrame 
end 

任何命令行输入或输出都按照以下方式编写:

pkg> add PackageName@vX.Y.Z 
pkg> add IJulia@v1.14.1

粗体: 表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“但是versicolorvirginica?并不多。”

警告或重要提示显示如下。

技巧和窍门显示如下。

联系我们

欢迎读者反馈。

一般反馈:如果您对本书的任何方面有疑问,请在邮件主题中提及书名,并通过customercare@packtpub.com给我们发邮件。

勘误: 尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,如果您能向我们报告,我们将不胜感激。请访问 www.packt.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。

盗版: 如果您在互联网上以任何形式发现了我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过发送链接至 copyright@packt.com 与我们联系。

如果您有兴趣成为作者: 如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com

评论

请留下评论。一旦您阅读并使用了这本书,为何不在您购买它的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,Packt 公司可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!

如需了解 Packt 的更多信息,请访问 packt.com

第一章:开始使用 Julia 编程

Julia 是一种高级、高性能的动态编程语言,专注于数值计算和通用编程。它相对较新——四位创建者,Jeff Bezanson、Stefan Karpinski、Viral Shah 和 Alan Edelman,于 2009 年着手创建它,2012 年首次公开提及该语言,当时他们发布了一篇博客文章,解释了他们的愿景和目标。2012 年被认为是 Julia 的官方诞生年份,使其仅有六岁。自其首次公开发布以来,Julia 已经收到了来自世界各地数百名科学家、程序员和工程师的代码贡献。它是开源的,源代码可在 GitHub 上找到,并且是拥有* 20,000 个星标(截至写作时,仍在计数)的最受欢迎的仓库之一。备受期待的第一个稳定版本 Julia v1.0 在 2018 年 8 月的伦敦 Julia 大会上发布,这是超过 700 名开源贡献者和数千名包创建者及早期用户的卓越合作的成果。到那时,该语言已经被下载超过两百万次了!

Julia 作为一种全新的替代品出现,用于传统的科学计算语言,这些语言要么是高效的,要么是快速的,但两者都不是。这被称为两种语言问题,其中初始原型代码是用动态、高效的语言(如 R 或 Python)编写的,这允许探索性编码和快速迭代,跳过了耗时的构建和编译时间。但后来,开发者被迫重写他们的程序(或者至少是程序中性能关键的部分),使用编译语言来满足科学计算的高性能要求。

Julia 的创建者认为,软件开发技术已经发展到足以支持一种结合高生产力和高性能的语言。这是他们的宣言,也是他们为 Julia 设定的目标:

"我们希望有一种开源的语言,拥有宽松的许可证。我们希望拥有 C 的速度和 Ruby 的动态性。我们希望有一种同构语言,拥有像 Lisp 一样的真正宏,但又有像 MATLAB 一样明显、熟悉的数学符号。我们希望它对通用编程的可用性像 Python 一样,对统计学的易用性像 R 一样,对字符串处理的自然性像 Perl 一样,对线性代数的强大性像 MATLAB 一样,在粘合程序方面像 shell 一样出色。一种学习起来非常简单,但又能让最严肃的黑客满意的简单语言。我们希望它是交互式的,我们希望它是编译的。"

"(我们提到它应该和 C 一样快吗?)"

看起来可能难以置信,Julia 已经成功满足了所有这些要求,创造了一种易于学习、直观、友好、高效且快速的独特语言。让我们更深入地了解一下所有这些特性。

本章我们将涵盖的主题包括:

  • 快速了解 Julia——它是什么,主要功能和优势,以及为什么它可能是您下一个项目的最佳选择

  • 如何设置和与本地机器上的 Julia 语言交互

  • 最好的 IDE 和编辑器用于高效的 Julia 开发

  • 通过了解其强大的 REPL 开始使用 Julia

  • 如何使用内置的包管理器Pkg通过第三方库扩展语言

技术要求

Julia 的包生态系统正在持续发展中,并且每天都有新的包版本发布。大多数时候,这是一个好消息,因为新版本带来了新功能和错误修复。然而,由于许多包仍在测试版(版本 0.x)中,任何新版本都可能引入破坏性更改。因此,书中展示的代码可能会停止工作。为了确保您的代码会产生与书中描述相同的结果,建议使用相同的包版本。以下是本章使用的外部包及其特定版本:

 IJulia@v1.14.1
OhMyREPL@v0.4.1
Revise@v0.7.14

为了安装特定版本的包,您需要运行:

pkg> add PackageName@vX.Y.Z 

例如:

pkg> add IJulia@v1.14.1

或者,您可以通过下载章节中提供的Project.toml文件,并使用pkg>实例化来安装所有使用的包:

julia> download("https://raw.githubusercontent.com/PacktPublishing/Julia-Programming-Projects/master/Chapter01/Project.toml", "Project.toml")
pkg> activate . 
pkg> instantiate

为什么选择 Julia?

简而言之,Julia 确实是一种新型的编程语言,它成功地结合了编译语言的高性能和动态语言的高灵活性,通过一种友好且直观的语法,从开始就让人感觉自然。Julia 是快速的(程序在运行时编译成针对多个*台的高效原生代码),通用的(标准库支持开箱即用的强大编程任务,包括异步 I/O、进程控制、并行和分布式计算、日志记录、性能分析、包管理等等),动态和可选类型的(它是动态类型的,具有可选的类型声明,并附带一个强大的读取-评估-打印循环(REPL)用于交互式和探索性编码)。它也是技术的(擅长数值计算)和可组合的(得益于其丰富的生态系统,这些包被设计成无缝且高性能地协同工作)。

尽管最初它专注于解决高性能数值分析和计算科学的需求,但最*的版本已经将语言定位在通用计算领域,许多专门的函数被从核心移动到专用模块中。因此,它也非常适合客户端和服务器端编程,这得益于其在并发、并行和分布式计算方面的强大能力。

Julia 实现了一种基于参数多态和多重调用的类型系统,它采用垃圾回收机制,使用即时求值,内置强大的正则表达式引擎,并且可以调用 C 和 Fortran 函数而无需粘合代码。

让我们来看看语言最重要的特性,那些使 Julia 突出的部分。如果您正在考虑将 Julia 用于您的下一个项目,您可以使用这个快速清单来对照您的需求。

良好的性能

Julia 性能的关键在于基于 LLVM 的即时编译器(JIT)和一系列战略性的设计决策,这些决策允许编译器生成接*甚至大多数情况下匹配 C 语言性能的代码。

为了让您了解 Julia 在这个方面的位置,官方网站提供了一系列针对其他主流语言的微基准测试(包括 C、Python、R、Java、JavaScript、Fortran、Go、Rust、MATLAB 和 Octave),这些语言实现了计算斐波那契数列、Mandelbrot 集合、快速排序以及其他一些算法。它们旨在评估编译器对常见代码模式(如函数调用、字符串解析、排序、迭代、递归等)的性能。基准测试的图表可在julialang.org/benchmarks/找到,该图表展示了 Julia 在所有测试中的一致性顶级性能。以下图表展示了这一点:

图片

如需了解更多关于测试方法的信息,您可以访问julialang.org/benchmarks/

简洁、易读且直观的语法

Julia 的创造者从其他语言中精心挑选了最成功的语法元素,目的是生成表达性强、简洁且易于阅读的代码。与 R、MATLAB 和 Python 等语言一样,Julia 提供了强大的表达式性语言结构,用于高级数值计算。它建立在现有数学编程语言的经验之上,同时也借鉴了流行的动态语言,如 Lisp、Perl、Python、Lua 和 Ruby。

为了让您快速了解 Julia 的惯用法,以下是如何打开一个文件、读取它、输出它,然后由 Julia 自动关闭文件的示例:

open(".viminfo") do io
    read(io, String) |> println
end  
.viminfo file for reading passing io, an IOStream instance, into the underlying code block. The stream is then read into a String that is finally displayed onto the console by piping it into the println function. The code is very readable and easy to understand if you have some coding experience, even if this is your first time looking at Julia code.

这种所谓的 do 语法(以 open 函数后的 do 部分为名)受到了 Ruby 的 blocks 的启发——实际上,它是将匿名函数作为方法参数传递的语法糖。在先前的例子中,它被有效地使用,以简洁地表达一个强大的设计模式,用于安全地处理文件,确保资源不会意外地被留下打开。

这表明了语言设计者对 Julia 的安全性、易用性、表达性、简洁性、易读性和直观性的关注程度。

强大且高效的动态类型系统

Julia 的类型系统是语言的关键特性,它对性能和生产力都有重大影响。类型系统是动态和可选的,这意味着开发者可以选择但不必须向编译器提供类型信息。如果没有提供,Julia 将执行类型推断,即从输入值的类型推断后续值的类型的过程。这是一种非常强大的技术,因为它使程序员从担心类型中解放出来,使他们能够专注于应用程序逻辑,并使学习曲线更加*缓。这对于原型设计和探索性编程特别有用,当事先不知道完整的约束和要求时。

然而,理解和正确使用类型系统提供了重要的性能优势。Julia 允许可选地添加类型信息,这使得可以指明某个值必须是特定类型。这是语言的一个基石,允许高效的函数分发,并促进为不同参数类型自动生成高效、专门的代码。类型系统允许定义丰富的类型层次结构,用户定义的类型与内置类型一样快速且紧凑。

设计用于并行性和分布式计算

如果 70 年代和 80 年代的语言是在有限的 CPU 和 RAM 资源严格要求的约束下设计的,那么 90 年代和 2000 年代的语言则持有乐观的预期,认为这些资源将永远扩展。然而,在过去的十年中,在这方面出现了一些停滞,转向了多 CPU、多核和分布式计算。在这方面,Julia 仅 6 年前才出现,与较老的语言相比,它具有优势,将并行和分布式计算作为其最重要的特性之一。

与其他语言的高效互操作

在采用新语言时,最严重的障碍之一是生态系统需要时间才能赶上——在最初,它无法提供与已经建立的语言质量相当和丰富的库。现在这个问题已经不那么严重了,因为 Julia 受益于一个庞大、热情且持续增长的开发者社区。但能够与其他语言无缝通信是一种非常有效的方式来丰富现有功能,并轻松补充任何缺失的功能。

Julia 具有直接调用 C 和 Fortran 函数的能力(即,无需粘合代码)——这对于科学计算尤为重要,在这些语言中,它们具有强大的存在感和悠久的历史。

可选包通过添加对其他语言编写的函数的调用支持来扩展这一功能,最值得注意的是通过 PyCall 调用 Python,还有其他一些,支持与 Java、C++、MATLAB、Rust 等语言交互。

强大的 REPL 和类似 shell 的功能

REPL 代表一个语言外壳,是一个命令行上的交互式计算机编程环境。Julia 拥有出色的 REPL,支持复杂的代码输入和评估。它包括一些强大的编辑功能,如可搜索的历史记录、自动补全和语法高亮,仅举几例。

它还包含三种特殊模式——shell,允许像在操作系统终端一样执行命令;help,提供在不离开 REPL 的情况下访问文档的功能;以及 pkg,用于安装和管理应用程序依赖项。

更多...

Julia 自带强大的包管理器,可以解决依赖关系,并处理额外包的添加和删除。像大多数现代语言一样,Julia 完全支持 Unicode。最后,它遵循宽松的 MIT 许可协议——它是免费和开源的。

安装 Julia

如果前面的部分让您决定为您的下一个项目使用 Julia,或者至少让您对了解更多感到好奇,那么是时候设置您的 Julia 开发环境了。

Julia 拥有出色的跨*台支持,可以在所有主要操作系统上运行。安装过程很简单——该语言可以在您的本地机器、虚拟机(VM)、Docker 容器或云中的某个服务器上设置。

让我们先看看本地安装选项,针对三大操作系统(Windows、Linux 和 macOS)。您可以自由地直接跳转到适合您的选项。

Windows

Windows 作为一个开发*台已经取得了长足的进步,并且有一些很好的替代方案可以让 Julia 运行起来。

官方 Windows 安装程序

最简单的方法是下载适用于您*台(32 位或 64 位)的 Windows 安装程序,从julialang.org/downloads/。获取.exe文件并运行它。按照标准安装程序进行操作,最后,您将拥有安装为程序的 Julia。双击julia.exe将打开一个带有 Julia REPL 的命令提示符,就像这里所示:

图片

使用 Chocolatey

Chocolatey 是 Windows 的包管理器,类似于 Linux 上的aptyum,或 Mac 上的brew。如果您还没有它,请按照chocolatey.org上的说明获取。

Chocolatey 拥有最新的 Julia 版本,可以通过以下搜索进行确认:

$ choco search julia 
Chocolatey v0.10.11 
Julia 1.0.0 [Approved] 
1 packages found. 

安装过程就像这样简单:

$ choco install julia 
Chocolatey v0.10.11 
Installing the following packages: 
julia 
By installing you accept licenses for the packages. 
Progress: Downloading Julia 1.0.0... 100% 
Julia v1.0.0 [Approved] 
Chocolatey installed 1/1 packages. 

Windows 子系统对于 Linux

Windows 10 最*新增的功能之一是 Linux 子系统。这允许在 Windows 上直接设置 Linux 开发环境,包括大多数命令行工具、实用程序和应用程序——无需修改,无需运行虚拟机(VM)的开销。

为了能够使用 Linux 子系统,你的 PC 必须运行 Windows 10 周年更新或更高版本的 64 位版本(构建 1607+)。它还需要首先启用——因此以管理员身份打开 PowerShell 并运行以下命令:

$ Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Windows-Subsystem-Linux 

一旦子系统被启用(可能需要重启计算机),你可以直接从 Windows Store 选择可用的 Linux 版本。在撰写本文时,有五个版本可供选择——Ubuntu、openSUSE、SLES、Debian 和 Kali。

Ubuntu 是 Windows 10 的默认选项,在 Windows Store 中拥有最好的用户评分,所以让我们选择它。它可以从 www.microsoft.com/en-us/store/p/ubuntu/9nblggh4msv6 安装。或者,你只需打开一个命令提示符并输入 $ bash。这将触发 Ubuntu Linux 子系统的安装。

一旦你发现自己处于 Linux 子系统的 shell 提示符,你就可以继续并输入安装 Julia 的命令。对于 Ubuntu,你需要运行以下命令:

$ sudo apt-get install julia

确保确认所需的选择——然后几分钟后,你应该就可以运行 Julia 了。

macOS

在 macOS 上安装 Julia 非常简单。主要有两种选择,取决于你是否更喜欢图形安装程序还是更习惯于在终端提示符前操作。

官方镜像

访问 julialang.org/downloads/ 并查找 macOS 软件包(.dmg)。下载完成后,双击 .dmg 文件,将 Julia 应用程序拖放到 /Applications 文件夹。现在你可以简单地打开 Julia 应用程序——它将启动一个新的终端会话,加载 Julia 环境,如下所示:

图片

Homebrew

Homebrew 是 macOS 上一个知名的包管理器,类似于 Linux 上的 aptyum。虽然安装 Julia 并非必需,但值得设置它,因为它在开发过程中非常有用,因为它可以无缝地安装数据库服务器、库和其他项目组件。

根据在 brew.sh 的说明,可以在终端窗口中运行以下命令来安装:

$ /usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)" 

可能需要一段时间,但一旦 Homebrew 安装完成,一个新的命令行工具 brew 将会可用。

最后,使用 $ brew cask install julia 将下载并安装 Julia 的最新版本。在这个过程中,它还会将 julia 二进制文件链接到 /usr/local/bin/julia,这样你就可以通过简单地输入 $ julia 来从命令行与语言交互。

一旦你收到安装成功的确认,你可以运行 $ julia 来启动一个新的 REPL 会话:

图片

Linux 和 FreeBSD

Julia 已经可用在主要 Linux 发行版的软件仓库中,但遗憾的是,这些并不都是最新的。例如,在撰写本文时,Ubuntu 提供的是 v0.4.5,Debian 是 v0.4.7。最佳方法是使用 Julia 下载页上提供的通用 Linux 二进制文件,在julialang.org/downloads/

请遵循对应您 Linux 发行版的说明,如julialang.org/downloads/platform.html#generic-binaries所示。

Docker

Docker 是一种提供操作系统级别虚拟化抽象额外层的软件技术。简单来说,Docker 设置了像 VM 一样的容器,但无需启动和维护 VM 的开销。您可以在所有主要操作系统上运行 Docker。

Docker 被广泛用作开发和部署策略,因此许多技术都以 Docker 镜像的形式 readily 可用,Julia 也不例外。

首先为您的*台安装 Docker。官方的 Julia 容器可以在 Docker 商店的store.docker.com/images/julia找到。去获取它。

如果您需要帮助设置 Docker 或安装容器,请参阅www.docker.com上的说明。

在命令提示符下,输入$ docker pull julia。一旦 Docker 配置了 Julia 镜像,使用$ docker exec -it --rm julia运行它。这将启动容器并加载一个新的 Julia REPL:

图片

JuliaPro

Julia 编程语言背后的公司 Julia Computing 提供了一种包含所有组件的发行版。它被称为JuliaPro,可以说是立即开始使用 Julia 的最简单方法。它包括编译器、分析器、Juno IDE 以及超过 160 个高质量的精选包,用于绘图、数据可视化、机器学习、数据库等。

您可以在shop.juliacomputing.com/Products/免费下载 JuliaPro(需要注册)。一旦您获得它,请遵循您*台特定的安装过程。完成后,您将拥有开始高效使用 Julia 所需的一切。

付费的企业版也提供了一些额外功能,例如 Excel 集成和支持 SLA。

JuliaBox

最后,还有 JuliaBox (www.juliabox.com),这是 Julia Computing 提供的另一项免费服务。JuliaBox 允许在他们的云中即时运行 Julia Docker 容器。它提供了对 IJulia Jupyter 笔记本(github.com/JuliaLang/IJulia.jl)的访问、与 Google Drive 的文件同步、导入 GitHub 仓库以及许多其他功能。

如果你不太熟悉 Jupyter 笔记本,你可以通过访问 jupyter.org 来了解更多信息。

选择 IDE

在使用编程语言时,IDE 非常重要。一个强大的源代码编辑器、代码补全、以及良好的代码检查器和调试器可以显著影响学习曲线和使用该语言的效率。你会很高兴地了解到,对于 Julia 来说,有一些非常好的 IDE 和编辑器选项——你可能会在这些选项中找到你最喜欢的一个。

IDE 的选择反映了整个语言的实用主义。从选择 LLVM 作为编译器,到提供从其他语言调用函数的高效方式,再到使用 git 和 GitHub 来驱动包管理器,Julia 核心团队采取了“不重复造轮子”的方法。遵循同样的思路,Julia 社区在现有的行业建立编辑器的基础上构建了强大的 IDE,例如 Atom 和 Visual Studio Code。

Juno (Atom)

Juno (junolab.org) 是最先进的 Julia 集成开发环境(IDE),也是 Julia 专业人士首选的默认编辑器。它基于 Atom 编辑器,可以被认为是官方的开发工具,因为它也随之前提到的 JuliaPro 发行版一起分发。

要获取它,你可以从 juliacomputing.com/products/juliapro.html 下载并安装 JuliaPro,或者手动安装 Atom 和所需的插件。

如果你选择手动安装,首先需要从 atom.io 下载 Atom。一旦启动并运行,转到设置面板(你可以使用快捷键 Ctrl/cmd,),然后转到安装面板。在搜索框中输入 uber-juno 并按 Enter。接下来,点击具有相同名称的包的安装按钮。Atom 将从这里开始,安装所有必需的 Atom 和 Julia 包。

一旦配置完成,IDE 选项将在 Atom 的菜单中可用,在 Packages > Julia 下。也可以从这里启用各种面板,以列出变量、可视化图表或搜索文档。

如需更多信息,请查看 junolab.orggithub.com/JunoLab/uber-juno/blob/master/setup.md

Visual Studio Code

Visual Studio Code 是微软的一个跨*台可扩展编辑器。它适用于所有三大*台,可在 code.visualstudio.com 获取。安装后,运行它并从菜单中选择视图 > 扩展或使用快捷键 ShiftCtrl/cmdX。搜索 julia 并从 julialang 安装 Julia 扩展。

Visual Studio Code 中的 Julia 支持(目前)不如 Juno 强大,但如果你更喜欢它,它也能提供极佳的编码体验,包括语法高亮、代码补全、悬停帮助、Julia 代码评估、代码检查、代码导航等功能。Visual Studio Code 比 Atom 更轻快,使用的资源也更少,这使得它在运行在性能较弱的工作站上时成为一个吸引人的选择(尽管 Atom 在最*版本中在这方面有了很大改进)。

扩展可能需要一点帮助来确定它在哪里可以找到 Julia 的二进制文件。如果是这种情况,你会收到一个信息丰富的错误消息,要求你设置julia.executablePath配置选项。这应该指向 julia 的二进制文件,并且取决于你的操作系统和安装 Julia 的方式(有关安装的详细信息,请参阅上一节)。

要设置配置,请转到“首选项”>“设置”(Ctrl/cmd),然后在右侧面板中,用于覆盖默认设置的面板,添加以下内容:

"julia.executablePath": "/path/to/your/julia/folder/bin/julia" 

IJulia (JuliaBox)

我们在上一节中已经提到了 JuliaBox (www.juliabox.com)——它允许在云中创建、编辑和运行 IJulia Jupyter 笔记本。IJulia 也可以安装在本地的开发机器上。

IJulia 是 Jupyter 交互式环境(也被 IPython 使用)的 Julia 语言后端。它允许我们通过 Jupyter/IPython 强大的图形笔记本与 Julia 语言进行交互,该笔记本将代码、格式化文本、数学和多媒体结合在一个文档中。

虽然 IJulia/Jupyter 并不是真正的 IDE,也不是经典的编辑器,但它是一个强大的编辑和执行 Julia 脚本的编程环境,特别受数据科学和科学计算领域的欢迎。让我们花几分钟时间来设置它。

启动一个新的 Julia REPL 并执行以下命令:

julia> using Pkg
julia> Pkg.add("IJulia")

这将安装IJulia包,同时还会添加一个名为Miniconda的必需的最小 Python 和 Jupyter 发行版。这个 Python 发行版是 Julia 专有的(不在你的PATH中)。完成后,继续执行以下命令:

julia> using IJulia
julia> notebook()

这将在你的默认浏览器中打开本地 Jupyter 安装的主页,在localhost:8888/tree。从工具栏中选择新建 > Julia 1.0.0(或你当前运行的版本)来创建一个新的笔记本。你现在可以使用嵌入的可执行 Julia 代码创建丰富的文档。

还有另一种运行 IJulia 作为桌面应用程序的方式,通过Interact。你可以下载它并尝试使用nteract.io/desktop

如果你刚开始使用 Jupyter,了解它将很有帮助。可以去jupyter.org查看。

你也可以在本书每一章的支持文件仓库中找到每个章节的 IJulia 笔记本。这些笔记本将允许你逐步查看我们编写的代码。例如,你可以在这个章节中找到代码 github.com/PacktPublishing/Julia-Programming-Projects/blob/master/Chapter01/Chapter%201.ipynb。你可以在你的电脑上下载它,并用本地 IJulia 安装打开,或者通过他们的 Google Drive 集成上传到 JuliaBox。

其他选项

上述选择是 Julia 最常见的 IDE 和编辑器选项。但还有一些其他的选择。

对于 vim 爱好者,也有 julia-vim (github.com/JuliaEditorSupport/julia-vim)。

如果你更喜欢使用 Emacs,你会很高兴地知道 Julia 也支持它 github.com/JuliaEditorSupport/julia-emacs

如果你更愿意使用 JetBrains 提供的 IDE(如 IntelliJ IDEA),你会很高兴地听说有一个插件可用,在 plugins.jetbrains.com/plugin/10413-julia

最后,还有对 Sublime Text 的支持,可在 github.com/JuliaEditorSupport/Julia-sublime 找到。该插件提供了良好的 Julia 编辑体验,支持语法高亮、代码补全和跳转到定义等功能。

开始使用 Julia

如果你跟随着本章的第一部分,到现在你应该已经有一个功能齐全的本地 Julia 安装,了解如何启动 Julia REPL 会话,并且你的首选 IDE 已经准备好进行编码。如果不是这样,请参考前面的部分。从现在开始,我们将进入正题——是时候编写一些 Julia 代码了!

Julia REPL

我们首先需要了解的是如何使用强大的 REPL。作为一名 Julia 开发者,你将花费大量时间进行探索性编程,与壳和文件系统交互,以及管理包。REPL 将是你的忠实伙伴。了解它将节省你很多时间。

缩写 REPL 代表读取-评估-打印循环。简单来说,它是一种特定语言的壳,一个交互式编码环境,允许输入表达式,评估它们,并输出结果。

REPL 非常有用,因为它们提供了一种简单的方式来与语言交互,尝试想法和原型,促进探索性编程和调试。在数据分析的上下文中,它尤其强大,因为可以快速连接到数据源,加载数据样本,然后进行切片和切块,快速测试不同的假设。

Julia 提供了一个出色的 REPL 体验,具有丰富的功能,涵盖了快速评估 Julia 语句、可搜索的历史记录、自动补全、语法高亮、专用帮助和 shell 模式等,仅举几例。

如果您没有安装有效的 Julia,请参阅安装 Julia部分。

与 REPL 交互

根据您的操作系统和偏好,REPL 可以通过简单地使用无参数的$ julia命令启动,或者通过双击julia可执行文件。

您将看到一个类似这样的屏幕(Julia 版本可能与我不同):

图片

现在,Julia 正在等待我们输入代码,逐行评估。您可以通过检查终端提示符来确认,它说julia>。这被称为julian 模式。让我们试试看。

您可以按照本章支持文件中提供的 IJulia Jupyter 笔记本进行操作。如果您不熟悉 Jupyter 且不知道如何在本地运行它,可以使用 Juliabox (juliabox.com)。您只需创建一个账户,登录,然后从github.com/PacktPublishing/Julia-Programming-Projects/blob/master/Chapter01/Chapter%201.ipynb加载笔记本。

输入以下行,每行输入后按Enter键:

julia> 2+2 
julia> 2³ 

因此,我们可以像使用简单计算器一样使用 Julia。虽然不是很实用,但这只是开始,展示了当我们处理复杂计算时,这种快速输入和反馈循环是多么强大。

println是一个非常有用的函数,它会打印接收到的任何值,并在之后添加一个新行。输入以下代码:

julia> println("Welcome to Julia") 

在每一行下面,您应该能看到每个表达式生成的输出。现在您的窗口应该看起来像这样。

julia> 2+2 
4 
julia> 2³ 
8 
julia> println("Welcome to Julia") 
Welcome to Julia 

让我们再试一些。REPL 一次解释一行,但所有内容都在一个共同的范围内评估。这意味着我们可以定义变量并在以后引用它们,如下所示:

julia> greeting = "Hello" 
"Hello" 

看起来很棒!让我们使用greeting变量和println

julia> println(greting) 
ERROR: UndefVarError: greting not defined 

哦!这里有个小错误,REPL 迅速返回了一个错误。不是greting,而是greeting。这也告诉我们,Julia 不允许在不正确初始化的情况下使用变量。它只是查找了greting变量,但没有成功——并抛出了一个未定义变量的错误。让我们再次尝试,这次更加小心:

julia> println(greeting) 
Hello 

好多了!我们可以看到输出:存储在greeting变量中的Hello值。

ans变量

REPL 提供了一些帮助功能,特定于这个交互式环境(在执行 Julia 脚本时不可用)。其中之一是ans变量,由 Julia 自动设置和更新。

如果你输入 julia> 2³——不出所料,你会得到 8。现在输入 julia> ans——你又会得到 8!这是怎么回事?ans 是一个只在 REPL 中存在的特殊变量,它会自动存储最后一个返回的值。当与 REPL 一起工作时,这可以非常有用,但更重要的是,你需要意识到它的存在,以免不小心声明了一个同名变量。否则,你可能会遇到一些非常难以理解的错误,因为你的变量值会不断被覆盖。

提示粘贴

REPL 内置了一个非常强大的功能,称为 提示粘贴。这允许我们复制粘贴并执行包含 julia> 提示和表达式输出的 Julia 代码和代码片段。当粘贴以 julia> 开头的文本时,它会激活。在这种情况下,只有以 julia> 开头的表达式会被解析,其他所有内容都会被忽略。这使得能够粘贴从另一个 REPL 会话或文档中复制出来的代码块,而无需清除提示和输出。

提示粘贴在 IJulia Jupyter 笔记本中不起作用。

要查看此功能的效果,请复制并粘贴以下代码片段,无需修改:

julia> using Dates 

julia> Dates.now() 
2018-09-02T21:13:03.122 
julia> ans 
2018-09-02T21:13:03.122 

如果一切顺利,两个表达式都应该输出你当前的时间,而不是代码片段中的时间,从而有效地替换代码片段中的结果为 Julia 会话中的结果。

由于默认 Windows 命令提示符的限制,此功能无法使用。

Tab 完成功能

在 Julian、pkg 和 help 模式下,你可以在输入函数的前几个字符后按 Tab 键,以获取所有匹配项的列表:

julia> pri[TAB] 
primitive type   print             print_shortest    print_with_color  println           printstyled  

它还可以用来将 LaTeX 数学符号替换为其 Unicode 等效符号。为此,输入一个反斜杠作为第一个字符,然后输入符号的前几个字符,然后按 Tab。这将完成符号的名称,或者如果存在多个匹配名称,将显示一个选项列表。再次按 Tab 在符号的完整名称上将会执行替换:

julia> \pi[TAB] 
julia> π 
π = 3.1415926535897... 

julia> \om[TAB] \omega \ominus 
julia> \ome[TAB] 
julia> \omega[TAB] 
julia> ω 

清理 REPL 作用域

Julia 没有空值的概念,所以你实际上无法从内存中释放变量。然而,如果你需要释放一个由变量引用的昂贵资源,你可以将其值替换为类似 0 的东西,之前的值将会自动被垃圾回收。你甚至可以直接通过调用 gc() 来立即调用垃圾回收器。

附加 REPL 模式

Julia REPL 内置了四种操作模式——并且可以根据需要定义附加模式。当前活动模式由其提示表示。在之前的例子中,我们使用了 julian 模式 julia>,它评估输入的表达式。其他三个可用模式是 helphelp?>shellshell> 和包管理,pkg>

可以通过在行首输入特定的字符来切换活动模式。提示符将相应地更改,以指示当前模式。模式将保持活动状态,直到当前行被评估,自动切换回 julian(pkg>模式除外,它是粘性的,即它将保持活动状态,直到通过在行首按退格键显式退出)。可以通过删除整行直到提示符变回julia>或按Ctrl + C来退出替代模式,而无需评估表达式。

使用帮助模式访问文档

帮助模式提供访问文档的功能,无需离开 REPL。要访问它,只需在行首输入?。你应该会看到help?>提示符。现在你可以输入文本,Julia 将会搜索匹配的文档条目,如下所示:

julia> ?  
help?> println 
search: println printstyled print_with_color print print_shortest sprint isprint 

  println([io::IO], xs...) 

  Print (using print) xs followed by a newline. If io is not supplied, prints to stdout. 

  Examples 
  ≡≡≡≡≡≡≡≡≡≡ 

  julia> println("Hello, world") 
  Hello, world 

  julia> io = IOBuffer(); 

  julia> println(io, "Hello, world") 

  julia> String(take!(io)) 
  "Hello, world\n"  

在 IJulia 中,通过在输入前加上所需的模式激活器来激活额外的模式。例如,要访问之前println函数的帮助,我们需要输入?println.

输出支持丰富的格式化,通过 Markdown:

julia> using Profile 
help?> Profile.print 

结果如以下截图所示:

可以查询更复杂的表达式,包括宏、类型和变量。

例如,help?> @time

或者help?> IO

Shell 模式

使用 shell 模式可以切换到类似于系统 shell 的命令行界面,以便直接执行操作系统命令。要进入该模式,请在 julian 提示符的非常开始处输入一个分号;

julia> ;  

输入;后,提示符(就地)变为shell>

要在 IJulia 中进入 shell 模式并执行 shell 命令,请在命令前加上;,例如;ls

现在,我们可以直接执行系统范围内的命令,无需将它们包裹在 Julia 代码中。这将列出你的repl_history.jl文件的最后十行。此文件由 Julia 用于记录在 REPL 中执行的命令的历史,因此你的输出将与我不同:

julia> using REPL
shell> tail -n 10 ~/.julia/logs/repl_history.jl
IO
# time: 2018-09-02 21:56:47 CEST
# mode: julia
REPL.find_hist_file()
# time: 2018-09-02 21:58:47 CEST
# mode: shell
tail -n 10 ~/.julia/logs/repl_history.jl 

当处于 REPL 模式时,我们可以访问 Julia 的 API,这使得这是一个非常强大的组合。例如,为了以编程方式获取 REPL 历史文件的路径,我们可以使用REPL.find_hist_file()函数,如下所示:

julia> REPL.find_hist_file() 
"/Users/adrian/.julia/logs/repl_history.jl" 

文件的路径将因人而异。

我们可以在 shell 模式下使用它,通过将命令用$()括起来:

shell> tail -n 10 $(REPL.find_hist_file()) 
    REPL.find_hist_file() 
# time: 2018-09-02 21:58:47 CEST 
# mode: shell 
    tail -n 10 ~/.julia/logs/repl_history.jl 
# time: 2018-09-02 22:00:03 CEST 
# mode: shell 
    tail -n 10 $(REPL.find_hist_file()) 

与帮助模式类似,shell 模式可以通过在行首按退格键或输入Ctrl + C来退出,而无需执行任何命令。

在 IJulia 中,可以通过在输入前加上;来执行命令,如下所示:

;tail -n 10 ~/.julia/logs/repl_history.jl 

搜索模式

除了帮助和 shell 模式之外,还有两种搜索模式。这些模式不一定是 Julia 特有的,它们是许多*nix 风格编辑应用程序的共同点。

同时按下Ctrl键和R键以启动反向增量搜索。提示符将变为(reverse-i-search)。开始输入你的查询,最*的搜索结果将显示出来。要找到更早的结果,再次按Ctrl + R

Ctrl + R的对应操作是Ctrl + S,它启动增量搜索。这两个可以一起使用,分别用于移动到上一个或下一个匹配结果。

startup.jl文件

如果你想每次运行 Julia 时自动执行一些代码,你可以将其添加到一个名为startup.jl的特殊文件中。这个文件不是自动创建的,所以你需要自己将它添加到你的 Julia 配置目录中。你添加到其中的任何代码都会在 Julia 启动时运行。让我们用 Julia 来做这个实验,并且练习一下我们学到的东西。

首先,进入 shell 模式并运行以下三个命令:

shell> mkdir $(dirname(REPL.find_hist_file()))/../config 

shell> cd $(dirname(REPL.find_hist_file()))/../config 
/Users/adrian/.julia/config 

shell> touch startup.jl 

然后,在 julian 模式下,执行以下操作:

julia> write("startup.jl", "println(\"Welcome to Julia!\")") 
28 

我们刚才做了什么?在 shell 模式下,我们在历史文件所在目录的上一级创建了一个名为config的新目录。然后我们cd进入新创建的文件夹,在那里我们创建了一个名为startup.jl的新文件。最后,我们让 Julia 向startup.jl文件添加了这一行代码"println(\"Welcome to Julia!\")"。下次我们启动 Julia REPL 时,我们会看到这个欢迎信息。看看这个:

图片

REPL 钩子

还可以在启动 REPL 会话之前定义一个将被自动调用的函数。为了实现这一点,你需要使用atreplinit(f)函数,它将一个单参数函数f注册为在交互式会话中初始化 REPL 界面之前调用。这个函数应该从startup.jl文件内部调用。

假设我们编辑了startup.jl文件,使其现在看起来像这样:

println("Welcome to Julia!") 

atreplinit() do (f) 
  println("And welcome to you too!") 
end 

我们的 REPL 现在会两次问候我们:

图片

可以使用atreplinit函数与isinteractive一起使用,isinteractive返回一个Boolean truefalse值,告诉我们 Julia 是否正在运行一个交互式会话。

退出 REPL

要退出 REPL,你可以输入^ DCtrl + D)。但是,这只有在行首(当文本缓冲区为空时)才会起作用。否则,只需输入^CCtrl + C)来首先中断(或取消)并清除行。你也可以运行exit(),这将停止当前 Julia 进程的执行。

要查看 REPL 中的完整键绑定列表以及如何自定义它们,你可以阅读官方文档docs.julialang.org/en/v1.0/stdlib/REPL/#Key-bindings-1

包系统

你的 Julia 安装附带了一个名为Pkg的强大包管理器。它处理所有预期的操作,例如添加和删除包、解决依赖关系、保持已安装包的更新、运行测试,甚至帮助我们发布自己的包。

包通过提供广泛的功能,无缝地扩展了核心语言,发挥着至关重要的作用。让我们看看最重要的包管理功能。

添加一个包

为了让Pkg知道,包必须添加到一个 Julia 可用的注册表中。Pkg支持同时与多个注册表一起工作——包括位于企业防火墙后面的私有注册表。默认情况下,Pkg配置为使用 Julia 的通用注册表,这是一个由 Julia 社区维护的免费和开源包的存储库。

Pkg是一个非常强大的工具,我们将在整本书中广泛使用它。在使用 Julia 进行开发时,包管理是一个常见的任务,因此我们将有多次机会逐步深入了解。现在,我们将迈出第一步,学习如何添加包——我们将通过添加一些强大的新功能到我们的 Julia 设置中来实现这一点。

OhMyREPL

我最喜欢的包之一叫做OhMyREPL。它为 Julia 的 REPL 实现了一些超级高效的功能,最显著的是语法高亮和括号配对。这是一个非常好的补充,使得交互式编码体验更加愉快和高效。

Julia 的Pkg以 GitHub 为中心。创建者将包作为 git 仓库分发,托管在 GitHub 上——甚至通用注册表本身也是一个 GitHub 仓库。OhMyREPL也不例外。在安装它之前,如果你想了解更多信息——使用第三方代码时,这总是一个好主意——你可以在github.com/KristofferC/OhMyREPL.jl查看。

请记住,即使它属于通用注册表,这些包也不提供任何保证,它们不一定经过 Julia 社区检查、验证或认可。然而,有一些常识性的指标可以提供关于包质量的洞察,最值得注意的是星标数量、测试状态以及最新 Julia 版本的兼容性。

为了添加一个包,我们首先需要进入Pkg的 REPL 模式。我们通过在行首输入]来实现这一点:

julia>] 

光标将改变以反映我们现在可以管理包了:

(v1.0) pkg> 

IJulia 目前不支持pkg>模式,但我们可以通过将它们包裹在pkg"..."中来执行Pkg命令,例如pkg"add OhMyREPL"

Pkg使用环境的概念,允许我们根据项目定义不同的和独立的包集合。这是一个非常强大且有用的功能,因为它消除了由依赖于同一包的不同版本的项目(所谓的依赖地狱)引起的依赖冲突。

由于我们尚未创建任何项目,Pkg将仅使用默认项目v1.0,由括号中的值指示。这代表您正在运行的 Julia 版本——并且您可能会根据您自己的 Julia 版本得到不同的默认项目。

现在,我们只需继续添加OhMyREPL

(v1.0) pkg> add OhMyREPL 
  Updating registry at `~/.julia/registries/General` 
  Updating git-repo `https://github.com/JuliaRegistries/General.git` 
 Resolving package versions... 
  Updating `~/.julia/environments/v1.0/Project.toml` 
  [5fb14364] + OhMyREPL v0.3.0 
  Updating `~/.julia/environments/v1.0/Manifest.toml` 
  [a8cc5b0e] + Crayons v1.0.0 
  [5fb14364] + OhMyREPL v0.3.0 
  [0796e94c] + Tokenize v0.5.2 
  [2a0f44e3] + Base64 
  [ade2ca70] + Dates 
  [8ba89e20] + Distributed 
  [b77e0a4c] + InteractiveUtils 
  [76f85450] + LibGit2 
  [8f399da3] + Libdl 
  [37e2e46d] + LinearAlgebra 
  [56ddb016] + Logging 
  [d6f4376e] + Markdown 
  [44cfe95a] + Pkg 
  [de0858da] + Printf 
  [3fa0cd96] + REPL 
  [9a3f8284] + Random 
  [ea8e919c] + SHA 
  [9e88b42a] + Serialization 
  [6462fe0b] + Sockets 
  [8dfed614] + Test 
  [cf7118a7] + UUIDs 
  [4ec0a83e] + Unicode  

上一条命令在 IJulia 中的等效命令是pkg"add OhMyREPL"

当在新的 Julia 安装上运行pkg> add时,Pkg将克隆 Julia 的通用注册表,并使用它来查找我们请求的包名。尽管我们只明确请求了OhMyREPL,但大多数 Julia 包都有外部依赖项,也需要安装。正如我们所见,我们的包有很多——但它们被Pkg迅速安装了。

自定义包安装

有时候我们可能想使用未添加到通用注册表的包。这通常适用于处于(早期)开发中的包或私有包。对于这种情况,我们可以传递pkg> add存储库的 URL,而不是包名:

(v1.0) pkg> add https://github.com/JuliaLang/Example.jl.git 
   Cloning git-repo `https://github.com/JuliaLang/Example.jl.git` 
  Updating git-repo `https://github.com/JuliaLang/Example.jl.git` 
 Resolving package versions... 
  Updating `~/.julia/environments/v1.0/Project.toml` 
  [7876af07] + Example v0.5.1+ #master (https://github.com/JuliaLang/Example.jl.git) 
  Updating `~/.julia/environments/v1.0/Manifest.toml` 
  [7876af07] + Example v0.5.1+ #master (https://github.com/JuliaLang/Example.jl.git) 

另一个常见场景是我们想安装包存储库的某个分支。这可以通过在包名或 URL 的末尾附加#name_of_the_branch轻松实现:

(v1.0) pkg> add OhMyREPL#master 
   Cloning git-repo `https://github.com/KristofferC/OhMyREPL.jl.git` 
  Updating git-repo `https://github.com/KristofferC/OhMyREPL.jl.git` 
 Resolving package versions... 
 Installed Crayons ─ v0.5.1 
  Updating `~/.julia/environments/v1.0/Project.toml` 
  [5fb14364] ~ OhMyREPL v0.3.0 ⇒ v0.3.0 #master (https://github.com/KristofferC/OhMyREPL.jl.git) 
  Updating `~/.julia/environments/v1.0/Manifest.toml`
  [a8cc5b0e] ↓ Crayons v1.0.0 ⇒ v0.5.1 
  [5fb14364] ~ OhMyREPL v0.3.0 ⇒ v0.3.0 #master (https://github.com/KristofferC/OhMyREPL.jl.git)

或者,对于未注册的包,使用以下命令:

(v1.0) pkg> add https://github.com/JuliaLang/Example.jl.git#master 

如果我们想回到使用已发布的分支,我们需要free这个包:

(v1.0) pkg> free OhMyREPL 
 Resolving package versions... 
  Updating `~/.julia/environments/v1.0/Project.toml` 
  [5fb14364] ~ OhMyREPL v0.3.0 #master (https://github.com/KristofferC/OhMyREPL.jl.git) ⇒ v0.3.0 
  Updating `~/.julia/environments/v1.0/Manifest.toml` 
  [a8cc5b0e] ↑ Crayons v0.5.1 ⇒ v1.0.0 
  [5fb14364] ~ OhMyREPL v0.3.0 #master (https://github.com/KristofferC/OhMyREPL.jl.git) ⇒ v0.3.0 

Revise

这很简单,但熟能生巧。让我们再添加一个!这次我们将安装Revise,这是另一个必不可少的包,它通过监控和检测您的 Julia 文件中的更改,并在需要时自动重新加载代码,从而实现流畅的开发工作流程。在Revise之前,加载当前 Julia 进程中的更改是出了名的困难,开发者通常被迫重启 REPL——这是一个耗时且低效的过程。Revise可以消除重启、加载包和等待代码编译的开销。

您可以通过阅读其文档来了解更多关于 Revise 的信息,文档位于timholy.github.io/Revise.jl/latest/

出乎意料的是,我们只需要再次调用add命令,这次传递Revise作为包名:

(v1.0) pkg> add Revise 
Resolving package versions... 
 Installed Revise ─ v0.7.5 
  Updating `~/.julia/environments/v1.0/Project.toml` 
  [295af30f] + Revise v0.7.5 
  Updating `~/.julia/environments/v1.0/Manifest.toml` 
  [bac558e1] + OrderedCollections v0.1.0
  [295af30f] + Revise v0.7.5 
  [7b1f6079] + FileWatching 

add命令也可以一次接受多个包。我们现在一个接一个地添加它们,为了学习目的,但否则,我们本可以执行(v1.0) pkg> add OhMyREPL Revise

检查包状态

我们可以通过使用恰如其分的status命令来检查我们项目的状态,以确认操作是否成功:

 (v1.0) pkg> status 
    Status `~/.julia/environments/v1.0/Project.toml` 
  [7876af07] Example v0.5.1+ #master (https://github.com/JuliaLang/Example.jl.git) 
  [5fb14364] OhMyREPL v0.3.0 
  [295af30f] Revise v0.7.5 

status命令显示所有已安装的包,包括从左到右的包的 ID 简短版本(称为UUID)、包名和版本号。在适当的情况下,它还会指示我们正在跟踪的分支,例如在Example的情况下,我们处于master分支。

Pkg 还支持一系列快捷方式,如果您想节省一些按键。在这种情况下,st 可以用来代替 status

使用包

一旦添加了包,为了访问其功能,我们必须将其引入作用域。这就是我们告诉 Julia 我们打算使用它的方法,请求编译器使其对我们可用。为此,首先,我们需要退出 pkg 模式。一旦我们处于 julian 提示符,为了使用 OhMyREPL,我们只需要执行:

julia> using OhMyREPL
[ Info: Precompiling OhMyREPL [5fb14364-9ced-5910-84b2-373655c76a03]

这就是全部——OhMyREPL 现在会自动增强当前的 REPL 会话。要看到它的实际效果,这里是一个 常规 REPL 的样子:

这里是相同的代码,通过 OhMyREPL 增强后的样子:

语法高亮和括号匹配使代码更易读,减少了语法错误。看起来很棒,不是吗?

OhMyREPL 还有更多酷炫的功能——您可以通过查看官方文档了解它们:kristofferc.github.io/OhMyREPL.jl/latest/index.html

再多一步

OhMyREPLRevise 是出色的开发工具,在所有 Julia 会话中自动加载它们非常有用。这正是 startup.jl 文件存在的原因——现在我们有了一个将其用于实际的机会(尽管我们诚挚的欢迎问候已经足够令人印象深刻了!)。

这里有一个小技巧,让我们开始——Julia 提供了一个 edit 函数,它会在配置的编辑器中打开一个文件。让我们用它来打开 startup.jl 文件:

julia> edit("~/.julia/config/startup.jl") 

这将使用默认编辑器打开文件。如果您还没有删除我们之前添加的欢迎信息,现在可以自由地这样做(除非您真的很喜欢它们,在这种情况下,您当然可以保留它们)。现在,Revise 需要在我们想要跟踪的任何其他模块之前使用——所以我们将希望它在文件顶部。至于 OhMyREPL,它可以放在下面。您的 startup.jl 文件应该看起来像这样:

using Revise 
using OhMyREPL 

保存并关闭编辑器。下次您启动 Julia 时,ReviseOhMyREPL 将已经加载。

更新包

Julia 提供了一个繁荣的生态系统,并且包的更新速度非常快。定期使用 pkg> update 检查更新是一个好习惯:

(v1.0) pkg> update 

当发出此命令时,Julia 首先检索通用仓库的最新版本,然后检查是否有任何包需要更新。

注意,发出 update 命令将更新所有可用的包。正如我们之前讨论的,当提到 依赖地狱 时,这可能不是最好的做法。在接下来的章节中,我们将看到如何与单个项目一起工作,并按单个应用程序管理依赖项。不过,了解您可以通过传递它们的名称来选择您想要更新的包是很重要的:

(v1.0) pkg> update OhMyREPL Revise 

Pkg 还提供了一个预览模式,它将显示运行特定命令时会发生什么,而实际上不会进行任何更改:

(v1.0) pkg> preview update OhMyREPL 
(v1.0) pkg> preview add HTTP 

pkg> update 的快捷键是 pkg> up

固定包

有时我们可能想要确保某些包不会被更新。这就是我们“固定”它们的时候:

(v1.0) pkg> pin OhMyREPL 
 Resolving package versions... 
  Updating `~/.julia/environments/v1.0/Project.toml` 
  [5fb14364] ~ OhMyREPL v0.3.0 ⇒ v0.3.0
  Updating `~/.julia/environments/v1.0/Manifest.toml` 
  [5fb14364] ~ OhMyREPL v0.3.0 ⇒ v0.3.0

固定包会标记为 符号——现在在检查状态时也会出现:

(v1.0) pkg> st 
    Status `~/.julia/environments/v1.0/Project.toml` 
  [5fb14364] OhMyREPL v0.3.0
  [295af30f] Revise v0.7.5 

如果我们想取消固定一个包,我们可以使用 pkg> free

(v1.0) pkg> free OhMyREPL 
  Updating `~/.julia/environments/v1.0/Project.toml` 
  [5fb14364] ~ OhMyREPL v0.3.0 ⇒ v0.3.0 
  Updating `~/.julia/environments/v1.0/Manifest.toml` 
  [5fb14364] ~ OhMyREPL v0.3.0 ⇒ v0.3.0 

(v1.0) pkg> st 
    Status `~/.julia/environments/v1.0/Project.toml` 
  [5fb14364] OhMyREPL v0.3.0 
  [295af30f] Revise v0.7.5 

移除包

如果你不再打算使用某些包,你可以删除(或使用 pkg> remove 命令移除它们)。例如,假设我们有以下配置:

(v1.0) pkg> st 
    Status `~/.julia/environments/v1.0/Project.toml` 
  [7876af07] Example v0.5.1+ #master (https://github.com/JuliaLang/Example.jl.git) 
  [5fb14364] OhMyREPL v0.3.0 
  [295af30f] Revise v0.7.5 

我们可以使用以下代码移除 Example 包:

(v1.0) pkg> remove Example 
  Updating `~/.julia/environments/v1.0/Project.toml` 
  [7876af07] - Example v0.5.1+ #master (https://github.com/JuliaLang/Example.jl.git) 
  Updating `~/.julia/environments/v1.0/Manifest.toml` 
  [7876af07] - Example v0.5.1+ #master ([`github.com/JuliaLang/Example.jl.git`](https://github.com/JuliaLang/Example.jl.git)) 

当然,它现在已经消失了:

(v1.0) pkg> st 
    Status `~/.julia/environments/v1.0/Project.toml` 
  [5fb14364] OhMyREPL v0.3.0 
  [295af30f] Revise v0.7.5 

pkg> remove 的快捷键是 pkg> rm

除了显式删除不需要的包外,Pkg 还有一个内置的自动清理功能。随着包版本的发展和包依赖关系的变化,一些已安装的包可能会变得过时,并且不再在任何现有项目中使用。Pkg 会记录所有使用过的项目,以便它可以遍历日志并确切地看到哪些项目仍然需要哪些包——从而识别出不再必要的包。这些可以使用 pkg> gc 命令一次性删除:

(v1.0) pkg> gc Active manifests at: `/Users/adrian/.julia/environments/v1.0/Manifest.toml` `/Users/adrian/.julia/environments/v0.7/Manifest.toml` Deleted /Users/adrian/.julia/packages/Acorn/exWWb: 40.852 KiB Deleted /Users/adrian/.julia/packages/BufferedStreams/hCA7W: 102.235 KiB Deleted /Users/adrian/.julia/packages/Crayons/e1SsX: 49.133 KiB Deleted /Users/adrian/.julia/packages/Example/ljaU2: 4.625 KiB Deleted /Users/adrian/.julia/packages/Genie/XOia2: 2.031 MiB Deleted /Users/adrian/.julia/packages/HTTPClient/ZQR55: 37.669 KiB Deleted /Users/adrian/.julia/packages/Homebrew/l8kUw: 277.296 MiB Deleted /Users/adrian/.julia/packages/LibCURL/Qs5og: 11.599 MiB Deleted /Users/adrian/.julia/packages/LibExpat/6jLDP: 127.247 KiB Deleted /Users/adrian/.julia/packages/LibPQ/N7lDU: 134.734 KiB Deleted /Users/adrian/.julia/packages/Libz/zMAun: 80.744 KiB Deleted /Users/adrian/.julia/packages/Nettle/LMDZh: 50.371 KiB
   Deleted /Users/adrian/.julia/packages/OhMyREPL/limOC: 448.493 KiB 
   Deleted /Users/adrian/.julia/packages/WinRPM/rDDZz: 24.925 KiB 
   Deleted 14 package installations : 292.001 MiB 

除了专门的 Pkg REPL 模式外,Julia 还提供了一个强大的 API,用于以编程方式管理包。我们不会涉及它,但如果你想了解它,你可以查看官方文档,链接为 docs.julialang.org/en/latest/stdlib/Pkg/#References-1

发现包

包发现还不是像它本可以那样简单,但有一些很好的选项。我建议从以下精心挑选的 Julia 包列表开始:github.com/svaksha/Julia.jl。它按领域分组了一个大量的包集合,包括人工智能、生物学、化学、数据库、图形、数据科学、物理学、统计学、超级计算等多个主题。

如果这还不够,你总是可以访问 discourse.julialang.org,在那里 Julia 社区讨论与语言相关的多种主题。你可以搜索和浏览现有的线程,特别是位于 discourse.julialang.org/c/community/packages 的包公告部分。

当然,你总是可以向社区寻求帮助——Julians 非常友好和欢迎,社区投入了大量精力进行管理,以保持讨论文明和建设性。创建新主题和回复需要免费 Discourse 账户。

最后,juliaobserver.com/packages 是一个第三方网站,提供了一种更精致的方式来查找包——它还执行 GitHub 搜索,因此也包括未注册的包。

注册与非注册

尽管我已在之前的段落中提到了这个话题,但我仍想以一个警告来结束对 Pkg 的讨论。一个包是否注册并不一定意味着它在功能或安全性方面经过了审查。它仅仅意味着该包已被创建者提交,并且它满足了一些技术要求,以便被添加到通用注册表中。包源代码可在 GitHub 上找到,就像任何开源软件一样,请确保你了解它做什么,应该如何使用,以及你接受许可条款。

这就结束了我们对包管理的初步讨论。但鉴于这是最常见的任务之一,我们将在未来的章节中反复回到这个话题,我们还将看到一些更高级使用的场景。

摘要

Julia 是一种新的编程语言,它利用了编译技术方面的最新创新,以提供动态编程语言的函数性、易用性和直观语法,同时以 C 的速度运行。其目标之一是消除所谓的两种语言问题——当用户用高级语言(如 R 和 Python)编写代码时,但性能关键部分必须用 C 或 C++ 重新编写。Julia 感觉像是一种动态语言,并提供了与这些语言相关的所有生产力特性。但与此同时,它消除了性能权衡,证明对于原型设计和探索性编程来说足够高效,对于性能关键的应用来说也足够高效。

它的内置包管理器提供了访问 2000 多个第三方库的权限,这些库无缝地扩展了语言,并提供了强大的新功能——我们已经学会了如何利用这些功能。而且如果还不够,Julia 还具有调用其他语言(如 C、Fortran、Python 或 Java)编写的函数的能力,仅举几个例子。

Julia 是免费且开源的(MIT 许可),可以在所有主要的操作系统上部署,包括 Windows、主要的 Linux 发行版和 macOS。它还提供了一些非常好的 IDE 和编辑器选项。

现在我们已经成功设置了我们的开发环境,是时候深入探讨 Julia 的语法了。在下一章中,我们将查看语言的一些基本构建块——定义变量和常量、操作和使用 Strings 和数值类型,以及与 Arrays 一起工作。作为 Julia 生产力的一种证明,这就是我们(连同我们将添加的一些额外包)在 Iris 花卉数据集上进行强大的探索性数据分析所需的一切(together with some extra packages that we'll add)。下一章见!

第二章:创建我们的第一个 Julia 应用程序

现在您已经安装了有效的 Julia 环境,并且您选择的 IDE 已经准备好运行,是时候将它们用于一些有用的任务了。在本章中,您将学习如何将 Julia 应用于数据分析——这是一个语言的核心领域,因此请期待给您留下深刻印象!

我们将学习如何使用 Julia 进行探索性数据分析。在这个过程中,我们将查看 RDatasets,这是一个提供超过 700 个学习数据集访问权限的包。我们将加载其中一个,即 Iris 花卉数据集,并使用标准数据分析函数对其进行操作。然后,我们将通过采用常见的可视化技术更仔细地查看数据。最后,我们将了解如何持久化和(重新)加载数据。

但是,为了做到这一点,我们首先需要查看语言的一些最重要的构建块。

在本章中,我们将涵盖以下主题:

  • 声明变量(和常量)

  • 处理字符 Strings 和正则表达式

  • 数字和数值类型

  • 我们的第一种 Julia 数据结构——TupleRangeArray

  • * 使用 Iris 花卉数据集进行探索性数据分析——RDatasets 和核心 Statistics

  • 使用 Gadfly 快速进行数据可视化

  • * 使用 CSVFeather 保存和加载数据表

  • 与 MongoDB 数据库交互

技术要求

Julia 的包生态系统正在不断发展,并且每天都有新的包版本发布。大多数时候这是一个好消息,因为新版本带来了新功能和错误修复。然而,由于许多包仍在测试版(版本 0.x)中,任何新版本都可能引入破坏性更改。因此,书中展示的代码可能无法正常工作。为了确保您的代码将产生与书中描述相同的结果,建议使用相同的包版本。以下是本章中使用的外部包及其特定版本:

CSV@v0.4.3
DataFrames@v0.15.2
Feather@v0.5.1
Gadfly@v1.0.1
IJulia@v1.14.1
JSON@v0.20.0
RDatasets@v0.6.1

为了安装特定版本的包,您需要运行:

pkg> add PackageName@vX.Y.Z 

例如:

pkg> add IJulia@v1.14.1

或者,您也可以通过下载章节中提供的 Project.toml 文件,并使用 pkg> 实例化以下命令来安装所有使用的包:

julia> download("https://raw.githubusercontent.com/PacktPublishing/Julia-Programming-Projects/master/Chapter02/Project.toml", "Project.toml")
pkg> activate . 
pkg> instantiate

定义变量

我们在上一章中看到,如何使用 REPL 来执行计算并将结果显示给我们。Julia 甚至通过设置 ans 变量来提供帮助,该变量自动保存最后一个计算值。

但是,如果我们想编写除了最简单的程序之外的内容,我们需要学习如何自己定义变量。在 Julia 中,变量只是一个与值相关联的名称。对变量命名有非常少的限制,并且名称本身没有语义意义(与 Ruby 不同,Ruby 中所有大写的名称被视为常量,语言会根据名称的不同对待变量)。

让我们看看一些例子:

julia> book = "Julia v1.0 By Example" 
julia> pi = 3.14 
julia> ANSWER = 42 
julia> my_first_name = "Adrian" 

你可以通过加载本章支持文件中提供的配套 Jupyter/IJulia 笔记本来跟随本章中的示例。

变量的名称是区分大小写的,这意味着ANSWERanswer(以及AnsweraNsWeR)是完全不同的:

julia> answer 
ERROR: UndefVarError: answer not defined 

也接受 Unicode 名称(UTF-8 编码)作为变量名称:

julia> δ = 130 

记住,你可以通过输入反斜杠(\)然后输入符号的名称,然后按Tab键来输入许多 Unicode 数学符号。例如,\pi[Tab]将输出π。

如果你的终端支持,表情符号也可以使用:

julia> ![](https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/jl-prog-proj/img/66a96d4b-ddc8-4a14-8261-fd28e4c890b6.png) = "apollo 11" 

变量的唯一明确禁止的名称是内置 Julia 语句的名称(doendtrycatchifelse,以及一些其他名称):

julia> do = 3 
ERROR: syntax: invalid "do" syntax 
julia> end = "Paris" 
ERROR: syntax: unexpected end 

尝试访问未定义的变量将导致错误:

julia> MysteryVar 
ERROR: UndefVarError: MysteryVar not defined 

诚然,这种语言没有强加很多限制,但一套代码风格约定总是有用的——对于一个开源语言来说更是如此。Julia 社区已经提炼了一套编写代码的最佳实践。在变量命名方面,名称应该是小写并且只包含一个单词;单词分隔可以使用下划线(_),但只有当没有它们名称难以阅读时才使用。例如,myvartotal_length_horizontal

由于读取名称的难度是一个主观问题,我对这种命名风格有点犹豫。我通常更喜欢在单词边界处分离的清晰度。但无论如何,遵循建议总是更好的,因为 Julia API 中的函数名称遵循它。通过遵循相同的约定,你的代码将保持一致性。

常量

常量是一旦声明就不能更改的变量。它们通过在前面加上const关键字来声明:

julia> const firstmonth = "January" 

在 Julia 中非常重要的一点是,常量不关心它们的值,而是关心它们的类型。现在讨论 Julia 中的类型还为时过早,所以现在只需说,类型代表我们正在处理的价值类型。例如,"abc"(在双引号内)是String类型,'a'(在单引号内)是Char类型,1000Int类型(因为它是一个整数)。因此,在 Julia 中,与大多数其他语言不同,只要类型保持不变,我们就可以更改分配给常量的值。例如,我们最初可以决定鸡蛋和牛奶是可接受的餐食选择,并改为vegetarian

julia> const mealoption = "vegetarian" 

如果我们决定改为vegan,我们可以在以后改变主意。Julia 会通过仅发出警告来允许这样做:

julia> mealoption = "vegan" 
WARNING: redefining constant mealoption 
"vegan" 

然而,尝试将mealoption = 2赋值将会导致错误:

julia> mealoption = 2 
ERROR: invalid redefinition of constant mealoption 

这是有意义的,对吧?谁听说过那种饮食?

然而,细微差别可能比这更微妙,尤其是在处理数字时:

julia> const amount = 10.25  
10.25 
julia> amount = 10 
ERROR: invalid redefinition of constant amount 

Julia 不允许这样做,因为从内部来看,1010.00 虽然具有相同的算术值,但它们是不同类型的值(10 是一个整数,而 10.00 是一个 float)。我们将在稍后更详细地了解数值类型,这样一切都会变得清晰:

julia> amount = 10.00 
WARNING: redefining constant amount 
10.0 

因此,我们需要将新值作为 10.00——一个 float 传递,以遵守相同的类型要求。

为什么常量很重要?

这主要关乎性能。常量可以作为全局值特别有用。因为全局变量是长期存在的,并且可以在代码的任何位置和任何时候进行修改,编译器在优化它们时会有困难。如果我们告诉编译器该值是常量,因此该值的类型不会改变,性能问题就可以得到优化。

当然,仅仅因为常量可以缓解由全局变量引起的某些关键性能问题,并不意味着我们被鼓励使用它们。在 Julia 中,像在其他语言中一样,应尽可能避免使用全局值。除了性能问题之外,它们还可能创建难以捕捉和理解的微妙错误。此外,请记住,由于 Julia 允许更改常量的值,意外的修改成为可能。

注释

常见的编程智慧如下:

"代码被阅读的次数远多于被编写的次数,因此要相应地计划。"

代码注释是一种强大的工具,可以使程序在以后更容易理解。在 Julia 中,注释用 # 符号标记。单行注释由 # 表示,并且直到行尾的所有内容都会被编译器忽略。多行注释被 #= ... =# 包围。在开闭注释标签之间的所有内容也会被编译器忽略。以下是一个示例:

julia> #= 
           Our company charges a fixed  
           $10 fee per transaction. 
       =# 
const flatfee = 10 # flat fee, per transaction  

在前面的代码片段中,我们可以看到多行和单行注释的实际应用。单行注释也可以放在行的开头。

字符串

字符串表示字符序列。我们可以通过在双引号之间包围相应的字符序列来创建字符串,如下所示:

julia> "Measuring programming progress by lines of code is like measuring aircraft building progress by weight." 

如果字符串中也包含引号,我们可以通过在它们前面加上反斜杠 \ 来转义这些引号:

julia> "Beta is Latin for \"still doesn't work\"." 

三引号字符串

然而,转义可能会变得混乱,所以有一个更好的处理方法——使用三引号 """..."""

julia> """Beta is Latin for "still doesn't work".""" 

在三引号内,不再需要转义单引号。但是,请确保单引号和三引号是分开的——否则编译器会感到困惑:

julia> """Beta is Latin for "still doesn't work"""" 
syntax: cannot juxtapose string literal 

当与多行文本一起使用时,三引号带来了一些额外的特殊功能。首先,如果开头的 """ 后面跟着一个换行符,这个换行符将被从字符串中删除。此外,空白被保留,但字符串将被缩进到最不缩进的行的级别:

julia> """ 
                  Hello 
           Look 
    Here"""

 julia> print(ans) 
Hello 
Look 
Here 
Here was removed).

在 Jupyter/IJulia 中看起来是这样的:

长箭头代表一个 Tab(在输出中由 \t 表示),而短箭头是一个空格。请注意,每一行都以一个空格作为开头——但它被移除了。最不缩进的行,即最后一行,被向左移动,移除了所有空白,并以 Here 开头,而其他行上的剩余空白被保留(现在以 Tab 开头)。

字符串拼接

两个或多个字符串可以通过使用星号 * 运算符拼接在一起,形成一个单独的字符串:

julia> "Hello " * "world!" "Hello world!" 

或者,我们可以调用 string 函数,传入我们想要拼接的所有单词:

julia> string("Itsy", " ", "Bitsy", " ", "Spider") 
"Itsy Bitsy Spider" 

拼接也可以与变量很好地配合使用:

julia> username = "Adrian" 
julia> greeting = "Good morning" 
julia> greeting * ", " * username 
"Good morning, Adrian" 

然而,同样,我们在处理类型时需要小心(类型是 Julia 的核心,所以这将会是一个经常出现的话题)。拼接仅适用于字符串:

julia> username = 9543794 
julia> greeting = "Good morning" 
julia> greeting * ", " * username 
MethodError: no method matching *(::String, ::Int64) 

即使不是所有参数都是字符串,通过调用 string 函数进行拼接也是有效的:

julia> string(greeting, ", ", username)
 "Good morning, 9543794"

因此,string 有一个额外的优势,它自动将参数转换为字符串。以下示例也适用:

julia> string(2, " and ", 3) 
"2 and 3"

但这不行:

julia> 2 * " and " * 3 
ERROR: MethodError: no method matching *(::Int64, ::String)

此外,还有一个 String 方法(首字母大写)。请记住,在 Julia 中名称是区分大小写的,所以 stringString 是两件不同的事情。对于大多数用途,我们需要小写的函数 string。如果您想了解 String,可以使用 Julia 的帮助系统来访问其文档。

字符串插值

当创建更长的、更复杂的字符串时,拼接可能会很嘈杂且容易出错。对于这种情况,我们最好使用 $ 符号将变量插值到字符串中:

julia> username = "Adrian" 
julia> greeting = "Good morning" 
julia> "$greeting, $username" 
"Good morning, Adrian" 

更复杂的表达式可以通过将其包裹在 $(...) 中进行插值:

julia> "$(uppercase(greeting)), $(reverse(username))" 
"GOOD MORNING, nairdA" 

这里我们调用了 uppercase 函数,它将字符串中的所有字母转换为大写字母——以及 reverse 函数,它反转单词中字母的顺序。它们的输出随后被插值到字符串中。在 $(...) 边界内,我们可以使用任何我们想要的 Julia 代码。

就像 string 函数一样,插值会负责将值转换为字符串:

julia> "The sum of 1 and 2 is $(1 + 2)" 
"The sum of 1 and 2 is 3"

字符串操作

字符串可以被当作字符列表来处理,因此我们可以对它们进行索引——也就是说,访问单词中某个位置的字符:

julia> str = "Nice to see you" 
julia> str[1] 
'N': ASCII/Unicode U+004e (category Lu: Letter, uppercase)

字符串 Nice to see you 的第一个字符是 N

Julia 中的索引是 1-based,这意味着列表的第一个元素位于索引 1。如果你之前编程过,这可能会让你感到惊讶,因为大多数编程语言使用 0-based 索引。然而,我向你保证,1-based 索引会让编码体验非常愉快且直接。

Julia 支持具有任意索引的数组,例如,可以从 0 开始编号。然而,任意索引是一个更高级的功能,我们在这里不会涉及。如果您对此好奇,可以查看官方文档docs.julialang.org/en/v1/devdocs/offset-arrays/

我们也可以通过使用 range 进行索引来提取字符串的一部分(子字符串),提供起始和结束位置:

julia> str[9:11] 
"see" 

重要的是要注意,通过单个值进行索引返回一个 Char,而通过 range 进行索引返回一个 String(记住,对于 Julia 来说,这是两件完全不同的事情):

julia> str[1:1] 
"N" 

N 是一个仅由一个字母组成的 String,正如其双引号所示:

julia> str[1] 
'N': ASCII/Unicode U+004e (category Lu: Letter, uppercase)

N 是一个 Char,正如单引号所示:

julia> str[1:1] == str[1] 
false 

它们不相等。

Unicode 和 UTF-8

在 Julia 中,字符串字面量使用 UTF-8 编码。UTF-8 是一种可变宽度编码,这意味着并非所有字符都使用相同数量的字节来表示。例如,ASCII 字符使用单个字节编码——但其他字符可以使用多达四个字节。这意味着并非每个 UTF-8 字符串的字节索引都是对应字符的有效索引。如果您在无效的字节索引处索引字符串,将会抛出错误。以下是我的意思:

julia> str = "Søren Kierkegaard was a Danish Philosopher" 
julia> str[1] 
'S': ASCII/Unicode U+0053 (category Lu: Letter, uppercase) 

我们可以正确地检索索引 1 处的字符:

julia> str[2] 
'ø': Unicode U+00f8 (category Ll: Letter, lowercase) 

在索引 2 处,我们成功获取了 ø 字符:

julia> str[3] 
StringIndexError("Søren Kierkegaard was a Danish Philosopher", 3) 

然而,ø 有两个字节,所以索引 3 也被 ø 使用,我们无法访问这个位置的字符串:

 julia> str[4] 
'r': ASCII/Unicode U+0072 (category Ll: Letter, lowercase) 

第三字母 r 在位置 4 被找到。

因此 ø 是一个占用位置 23 的双字节字符——所以索引 3 是无效的,匹配 ø 的第二个字节。下一个有效索引可以使用 nextind(str, 2) 来计算——但推荐的方式是使用字符迭代(我们将在本章稍后讨论 for 循环):

julia> for s in str 
           println(s) 
       end 
S 
ø 
r 
e 
n 

K 
... output truncated...

由于可变长度编码,字符串中的字符数不一定与最后一个索引相同(如您所见,第三个字母 r 在索引 4 处):

julia> length(str) 42 
julia> str[42] 'e': ASCII/Unicode U+0065 (category Ll: Letter, lowercase) 

对于此类情况,Julia 提供了 end 关键字,它可以作为最后一个索引的快捷方式。您可以使用 end 进行算术和其他操作,就像一个普通值一样:

julia> str[end] 
'r': ASCII/Unicode U+0072 (category Ll: Letter, lowercase) 
julia> str[end-10:end] 
"Philosopher"

end 值可以使用 endof(str) 函数进行程序计算。尝试在字符串的界限之外进行索引将导致 BoundsError

julia> str[end+1] 
ERROR: BoundsError: attempt to access "Søren Kierkegaard was a Danish Philosopher" 
  at index [44]

正则表达式

正则表达式用于在字符串内部进行强大的子字符串模式匹配。它们可以根据模式在字符串中搜索子字符串,然后提取或替换匹配项。Julia 提供了对 Perl 兼容正则表达式的支持。

输入正则表达式的最常见方式是使用所谓的非标准字符串字面量。这些看起来像常规的双引号字符串,但带有特殊的前缀。在正则表达式的例子中,这个前缀是"r"。前缀提供了一种与普通字符串字面量不同的行为。

例如,为了定义一个匹配所有字母的正则字符串,我们可以使用r"[a-zA-Z]*"。

Julia 提供了相当多的非标准字符串字面量——如果我们想的话,我们甚至可以定义自己的。最广泛使用的是正则表达式(r"...")、字节数组字面量(b"...")、版本号字面量(v"...")和包管理命令(pkg"...")。

下面是如何在 Julia 中构建正则表达式——它匹配介于 0 和 9 之间的数字:

julia> reg = r"[0-9]+" 
r"[0-9]+" 
julia> match(reg, "It was 1970") 
RegexMatch("1970") 

我们的正则表达式匹配子字符串1970

我们可以通过使用typeof函数检查其type来确认非标准字符串字面量reg实际上是一个Regex而不是一个普通的String

julia> typeof(reg) 
Regex 

这揭示了还有一个Regex构造函数可用:

julia> Regex("[0-9]+") 
r"[0-9]+" 

这两个构造函数类似:

julia> Regex("[0-9]+") == reg 
true

当我们需要使用更复杂的字符串创建正则表达式,可能包括插值或连接时,使用构造函数可能很有用。但通常,r"..."格式更常用。

通过使用一些组合的标志imsx,可以影响正则表达式的行为。这些修饰符必须放在关闭双引号标记之后:

julia> match(r"it was", "It was 1970") # case-sensitive no match 
julia> match(r"it was"i, "It was 1970") # case-insensitive match 
RegexMatch("It was") 

如您所预期,i执行不区分大小写的模式匹配。如果没有i修饰符,match返回nothing——一个特殊值,在交互式提示符中不打印任何内容,以指示正则表达式不匹配给定的字符串。

这些是可用的修饰符:

  • i—不区分大小写的模式匹配。

  • m—将字符串视为多行。

  • s—将字符串视为单行。

  • x—告诉正则表达式解析器忽略大多数既不是转义也不是在字符类内的空白。您可以使用此功能将正则表达式分成(稍微)更易读的部分。#字符也被视为元字符,引入注释,就像在普通代码中一样。

如果我们只想检查正则表达式或子字符串是否包含在字符串中,而不想提取或替换匹配项,则occursin函数更为简洁:

julia> occursin(r"hello", "It was 1970")  
false 
julia> occursin(r"19", "It was 1970") 
true 

当正则表达式匹配时,它返回一个RegexMatch对象。这些对象封装了表达式的匹配方式,包括匹配的子字符串和任何捕获的子字符串:

julia> alice_in_wonderland = "Why, sometimes I've believed as many as six impossible things before breakfast." 

julia> m = match(r"(\w+)+", alice_in_wonderland) 
RegexMatch("Why", 1="Why") 
Why.

我们还可以指定开始搜索的索引:

m = match(r"(\w+)+", alice_in_wonderland, 6) 
RegexMatch("sometimes", 1="sometimes") 

让我们尝试一个稍微复杂一些的例子:

julia> m = match(r"((\w+)(\s+|\W+))", alice_in_wonderland) 
RegexMatch("Why, ", 1="Why, ", 2="Why", 3=", ") 

结果的RegexMatch对象m公开以下属性(或在 Julia 的说法中,字段):

  • m.matchWhy)包含匹配的整个子字符串。

  • m.captures(一个包含WhyWhy,的字符串数组)表示捕获的子字符串。

  • m.offset,整个匹配开始的偏移量(在我们的例子中是 1)。

  • m.offsets,捕获子字符串的偏移量作为整数数组(在我们的例子中是 [1, 1, 4])。

Julia 不提供 g 修饰符,用于 贪婪全局 匹配。如果你需要所有匹配项,你可以使用 eachmatch() 函数遍历它们,如下所示:

julia> for m in eachmatch(r"((\w+)(\s+|\W+))", alice_in_wonderland) 
           println(m) 
end 

或者,我们可以使用 collect() 函数将所有匹配项放入一个列表中:

julia> collect(eachmatch(r"((\w+)(\s+|\W+))", alice_in_wonderland))
 13-element Array{RegexMatch,1}: 
 RegexMatch("Why, ", 1="Why, ", 2="Why", 3=", ") 
 RegexMatch("sometimes ", 1="sometimes ", 2="sometimes", 3=" ") 
 RegexMatch("I'", 1="I'", 2="I", 3="'") 
 RegexMatch("ve ", 1="ve ", 2="ve", 3=" ") 
 RegexMatch("believed ", 1="believed ", 2="believed", 3=" ") 
 RegexMatch("as ", 1="as ", 2="as", 3=" ") 
 RegexMatch("many ", 1="many ", 2="many", 3=" ") 
 RegexMatch("as ", 1="as ", 2="as", 3=" ") 
 RegexMatch("six ", 1="six ", 2="six", 3=" ") 
 RegexMatch("impossible ", 1="impossible ", 2="impossible", 3=" ") 
 RegexMatch("things ", 1="things ", 2="things", 3=" ") 
 RegexMatch("before ", 1="before ", 2="before", 3=" ") 
 RegexMatch("breakfast.", 1="breakfast.", 2="breakfast", 3=".")

想要了解更多关于正则表达式的信息,请查看官方文档:docs.julialang.org/en/stable/manual/strings/#Regular-Expressions-1

原始字符串字面量

如果需要定义一个不执行插值或转义的字符串,例如表示可能包含 $\ 的其他语言的代码,这些字符可能会干扰 Julia 解析器,你可以使用原始字符串。它们使用 raw"..." 构造,并创建包含所包含字符的普通 String 对象,这些字符与输入的完全一致,没有插值或转义:

julia> "This $will error out" 
ERROR: UndefVarError: will not defined 

在字符串中放置一个 $ 将导致 Julia 执行插值并查找名为 will 的变量:

julia> raw"This $will work" 
"This \$will work" 

但是,通过使用原始字符串,$ 符号将被忽略(或者更确切地说,自动转义,如输出所示)。

数字

Julia 提供了广泛的原始数字类型,以及完整的算术和位运算符以及标准数学函数。我们有丰富的数字类型层次结构可供使用,其中最通用的是 Number——它定义了两个子类型,ComplexReal。相反,Real 有四个子类型——AbstractFloatIntegerIrrationalRational。最后,Integer 分支为四个其他子类型——BigIntBoolSignedUnsigned

让我们来看看数字最重要的几个类别。

整数

文本整数简单地表示如下:

julia> 42 

默认的整数类型,称为 Int,取决于代码执行的系统架构。它可以是 Int32Int64。在我的 64 位系统上,我得到它如下:

julia> typeof(42) 
Int64 

Int 类型将反映这一点,因为它只是 Int32Int64 的别名:

julia> @show Int 
Int = Int64 

溢出行为

最小值和最大值由 typemin()typemax() 函数给出:

julia> typemin(Int), typemax(Int) 
(-9223372036854775808, 9223372036854775807) 

尝试使用超出最小值和最大值定义的边界之外的值不会抛出错误(甚至警告),而是导致环绕行为(意味着它会在另一端跳过):

julia> typemin(Int) - 1 
9223372036854775807 
julia> typemin(Int) - 1 == typemax(Int) 
true 

从最小值减去 1 将返回最大值:

julia> typemax(Int) + 1 == typemin(Int) 
true

反之亦然——将 1 添加到最大值将返回最小值。

对于处理这些范围之外的值,我们将使用 BigInt 类型:

julia> BigInt(typemax(Int)) + 1 
9223372036854775808

这里没有环绕;结果是我们所期望的。

浮点数

浮点数由由点分隔的数值表示:

julia> 3.14 
3.14 
julia> -1.0 
-1.0 
julia> 0.25 
0.25 
julia> .5  
0.5  

默认情况下,它们是 Float64 值,但可以转换为 Float32

julia> typeof(1.) 
Float64 
julia> f32 = Float32(1.) 
1.0f0 
julia> typeof(f32) 
Float32 

为了提高可读性,下划线(_)分隔符可以与整数和浮点数一起使用:

julia> 1_000_000, 0.000_000_005 
(1000000, 5.0e-9)

有理数

Julia 还提供了有理数类型。这允许我们处理精确的比率,而不是必须处理浮点数固有的精度损失。有理数以它们的分子和分母值表示,用两个正斜杠 // 分隔:

julia> 3//2 
3//2

如果没有数据丢失,有理数可以转换为其他类型:

julia> 1//2 + 2//4 
1//1 

julia> Int(1//1) 
1 

julia> float(1//3) 
0.3333333333333333 

julia> Int(1//3) 
ERROR: InexactError: Int64(Int64, 1//3) 

julia> float(1//3) == 1/3 
true 

Julia 还包括对复数的支持。我们不会详细讨论它们,但你可以在官方文档中阅读有关该主题的内容,链接为docs.julialang.org/en/v1/manual/complex-and-rational-numbers/#Complex-Numbers-1

数值运算符

Julia 支持其数值类型的完整范围的算术运算符:

  • + —(一元和二元加)

  • - —(一元和二元减)

  • * —(乘)

  • / —(除)

  • \ —(倒数除)

  • ^ —(幂)

  • % —(余数)

语言还支持每个这些的便捷更新运算符(+=,-=,*=,/=,\=,÷=,%=,和 ^=)。这里它们是野生的:

julia> a = 2 
2 
julia> a *= 3 # equivalent of a = a * 3 
6 
julia> a ^= 2 # equivalent of a = a ^ 2 
36 
julia> a += 4 # equivalent of a = a + 4 
40  

可以使用以下一组运算符执行数值比较:

  • == —(相等)

  • != —(不等)

  • < —(小于)

  • <= —(小于等于)

  • > —(大于)

  • >= —(大于等于)

在 Julia 中,比较也可以链式使用:

julia> 10 > 5 < 6 == 6 >= 3 != 2 
true 

向量化点操作符

Julia 为每个二元运算符定义了相应的 操作。这些操作旨在逐元素与值集合(称为 向量化)一起工作。也就是说,被 的运算符应用于集合中的每个元素。

在以下示例中,我们将对 first_five_fib 集合中的每个元素进行*方:

julia> first_five_fib = [1, 1, 2, 3, 5] 
5-element Array{Int64,1}: 
 1 
 1 
 2 
 3 
 5 
julia> first_five_fib .^ 2 
5-element Array{Int64,1}: 
  1 
  1 
  4 
  9 
 25 

在上一个示例中,first_five_fib 没有被修改,返回的结果集合,但还有 更新运算符也可用,它们在原地更新值。它们与之前讨论的更新运算符(增加了 )相匹配。例如,要就地更新 first_five_fib,我们会使用以下代码:

julia> first_five_fib .^= 2 

向量化代码是语言的一个重要部分,因为它具有可读性和简洁性,同时也因为它提供了重要的性能优化。更多详情,请查看docs.julialang.org/en/stable/manual/functions/#man-vectorized-1

还有更多

这一节只是触及了表面。要深入了解 Julia 的数值类型,请阅读官方文档docs.julialang.org/en/stable/manual/mathematical-operations/

元组

元组是 Julia 中最简单的数据类型和结构之一。它们可以有任意长度,可以包含任何类型的值——但它们是不可变的。一旦创建,元组就不能修改。可以使用字面量元组表示法创建元组,通过在大括号(...)内包裹逗号分隔的值:

(1, 2, 3)

julia> ("a", 4, 12.5) 
("a", 4, 12.5) 

为了定义一个只有一个元素的元组,我们一定不要忘记尾随的逗号:

julia> (1,) 
(1,) 

但省略括号是可以的:

julia> 'e', 2 
('e', 2) 

julia> 1, 
(1,) 

我们可以索引元组来访问它们的元素:

julia> lang = ("Julia", v"1.0") 
("Julia", v"1.0.0") 

julia> lang[2] 
v"1.0.0" 

向量化操作也适用于元组:

julia> (3,4) .+ (1,1) (4, 5)

命名元组

命名元组表示一个带有标签项的元组。我们可以通过标签或索引访问各个组件:

julia> skills = (language = "Julia", version = v"1.0") 
(language = "Julia", version = v"1.0.0") 

julia> skills.language 
"Julia"

 julia> skills[1] 
"Julia" 

命名元组可以非常强大,因为它们类似于完整对象,但限制在于它们是不可变的。

范围

我们在之前学习如何索引字符串时已经看到了范围。它们可以像以下这样简单:

julia> r = 1:20 
1:20 

与之前的集合一样,我们可以对范围进行索引:

julia> abc = 'a':'z' 
'a':1:'z' 

julia> abc[10] 
'j': ASCII/Unicode U+006a (category Ll: Letter, lowercase) 

julia> abc[end] 
'z': ASCII/Unicode U+007a (category Ll: Letter, lowercase) 

可以使用展开运算符"..."将范围展开为其对应的值。例如,我们可以将其展开成元组:

julia> (1:20...,) 
(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20) 

我们还可以将其展开成列表:

julia> [1:20...] 
20-element Array{Int64,1}

对于元组也是如此,它们还可以被展开成列表,以及其他东西:[(1,2,3)...]

我们可以看到,范围默认以增量为一的步长。我们可以通过传递一个可选的步长参数来改变它。以下是一个从020且步长为五的范围的例子:

julia> (0:5:20...,)  
(0, 5, 10, 15, 20) 

现在我们的值从55

这也打开了以负步长递减顺序前进的可能性:

julia> (20:-5:-20...,) 
(20, 15, 10, 5, 0, -5, -10, -15, -20) 

范围不仅限于整数——您之前已经看到了chars的范围;这些是floats的范围:

julia> (0.5:10) 
0.5:1.0:9.5 
julia> (0.5:10...,) 
(0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.5, 9.5)

我们还可以使用collect函数将范围扩展到列表(数组)中:

julia> collect(0.5:0.5:10) 
20-element Array{Float64,1} 

数组

数组是一种数据结构(以及相应的类型),它表示一个有序的元素集合。更具体地说,在 Julia 中,数组是一个存储在多维网格中的对象的集合。

数组可以有任意数量的维度,由它们的类型和维度数定义——Array{Type, Dimensions}

一维数组,也称为向量,可以使用数组字面量表示法轻松定义,即方括号[...]

julia> [1, 2, 3]  
3-element Array{Int64,1}: 
 1 
 2 
 3 

您还可以约束元素的类型:

julia> Float32[1, 2, 3, 4] 
4-element Array{Float32,1}: 
 1.0 
 2.0 
 3.0 
 4.0 

二维数组(也称为矩阵)可以使用相同的数组字面量表示法初始化,但这次不需要逗号:

julia> [1 2 3 4] 
1×4 Array{Int64,2}: 
 1  2  3  4 

我们可以使用分号添加更多行:

julia> [1 2 3; 4 5 6; 7 8 9] 
3×3 Array{Int64,2}: 
 1  2  3 
 4  5  6 
 7  8  9 

Julia 附带了许多函数,可以构造和初始化具有不同值的数组,例如zeroesonestruesfalsessimilarrandfill等。以下是一些实际应用的例子:

julia> zeros(Int, 2) 
2-element Array{Int64,1}: 
 0 
 0

 julia> zeros(Float64, 3) 
3-element Array{Float64,1}: 
 0.0 
 0.0 
 0.0 

julia> ones(2) 
2-element Array{Float64,1}: 
 1.0 
 1.0 

julia> ones(Int, 2) 
2-element Array{Int64,1}: 
 1 
 1 

julia> ones(Int, 3, 4) 
3×4 Array{Int64,2}: 
 1  1  1  1 
 1  1  1  1 
 1  1  1  1 

julia> trues(2) 
2-element BitArray{1}: 
 true 
 true 

julia> rand(Int, 4, 2) 
4×2 Array{Int64,2}: 
  9141724849782088627   6682031028895615978 
 -3827856130755187476  -1731760524632072533 
 -3369983903467340663  -7550830795386270701 
 -3159829068325670125   1153092130078644307 

julia> rand(Char, 3, 2) 
3×2 Array{Char,2}: 
 '\U63e7a'  '\Ub8723' 
 '\Uda56f'  ![](https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/jl-prog-proj/img/9c79f8c9-996c-43e5-99cb-3056f80192f6.png) 
 '\U7b7fd'  '\U5f749' 

julia> fill(42, 2, 3) 
2×3 Array{Int64,2}: 
 42  42  42 
 42  42  42

Array元素可以通过它们的索引访问,为每个维度传递一个值:

julia> arr1d = rand(5) 5-element Array{Float64,1}: 0.845359 0.0758361 0.379544 0.382333 0.240184 
julia> arr1d[5] 
 0.240184 
julia> arr2d = rand(5,2) 
5×2 Array{Float64,2}: 
 0.838952  0.312295 
 0.800917  0.253152 
 0.480604  0.49218 
 0.716717  0.889667 
 0.703998  0.773618 

julia> arr2d[4, 1] 
0.7167165812985592 

我们还可以传递一个冒号(:)来选择整个维度内的所有索引——或者一个范围来定义子选择:

julia> arr2d = rand(5,5) 
5×5 Array{Float64,2}: 
 0.618041  0.887638   0.633995  0.868588  0.19461 
 0.400213  0.699705   0.719709  0.328922  0.326825 
 0.322572  0.807488   0.866489  0.960801  0.476889 
 0.716221  0.504356   0.206264  0.600758  0.843445 
 0.705491  0.0334613  0.240025  0.235351  0.740302 

这就是我们选择行13和列35的方式:

julia> arr2d[1:3, 3:5] 
3×3 Array{Float64,2}: 
 0.633995  0.868588  0.19461 
 0.719709  0.328922  0.326825 
 0.866489  0.960801  0.476889 

单独的冒号:代表所有——所以这里我们选择了行和列35

julia> arr2d[:, 3:5] 
5×3 Array{Float64,2}: 
 0.633995  0.868588  0.19461 
 0.719709  0.328922  0.326825 
 0.866489  0.960801  0.476889 
 0.206264  0.600758  0.843445 
 0.240025  0.235351  0.740302

另一个选项是布尔数组Array来选择其true索引处的元素。在这里,我们选择了对应于true值的行和列35

julia> arr2d[[true, false, true, true, false], 3:5] 
3×3 Array{Float64,2}: 
 0.633995  0.868588  0.19461 
 0.866489  0.960801  0.476889 
 0.206264  0.600758  0.843445

与数组索引类似,我们也可以将值赋给选定的项:

julia> arr2d[1, 1] = 0.0 

julia> arr2d[[true, false, true, true, false], 3:5] = ones(3, 3) 
julia> arr2d 
5×5 Array{Float64,2}: 
 0.0       0.641646  1.0       1.0        1.0      
 0.750895  0.842909  0.818378  0.484694   0.661247 
 0.938833  0.193142  1.0       1.0        1.0      
 0.195541  0.338319  1.0       1.0        1.0      
 0.546298  0.920886  0.720724  0.0529883  0.238986 

迭代

遍历数组的最简单方法是使用for构造:

for element in yourarray 
    # do something with element 
end 

这里有一个例子:

julia> for person in ["Alison", "James", "Cohen"] 
           println("Hello $person") 
       end 

Hello Alison 
Hello James 
Hello Cohen 

如果你还需要在迭代时获取索引,Julia 提供了eachindex(yourarray)迭代器:

julia> people = ["Alison", "James", "Cohen"] 
3-element Array{String,1}: 
 "Alison" 
 "James" 
 "Cohen" 

julia> for i in eachindex(people) 
           println("$i. $(people[i])") 
       end

 1\. Alison 
2\. James 
3\. Cohen 

修改数组

我们可以使用push!函数向集合的末尾添加更多元素:

julia> arr = [1, 2, 3] 
3-element Array{Int64,1}: 
 1 
 2 
 3

 julia> push!(arr, 4) 
4-element Array{Int64,1}: 
 1 
 2 
 3 
 4 

julia> push!(arr, 5, 6, 7) 
7-element Array{Int64,1}: 
  1 
  2 
  3 
  4 
  5 
  6 
  7 

注意push!函数结尾的感叹号!。这在 Julia 中是一个完全合法的函数名。这是一个约定,用来警告该函数是修改性的——也就是说,它将修改传递给它的数据,而不是返回一个新值。

我们可以使用pop!从数组的末尾删除元素:

julia> pop!(arr) 
7 

julia> arr 
6-element Array{Int64,1}: 
 1 
 2 
 3 
 4 
 5 
 6 

调用pop!函数已经移除了arr的最后一个元素并返回了它。

如果我们要删除除了最后一个元素之外的其他元素,我们可以使用deleteat!函数,表示要删除的索引:

julia> deleteat!(arr, 3) 
5-element Array{Int64,1}: 
 1 
 2 
 4 
 5 
 6 

最后,关于修改数组有一个警告。在 Julia 中,数组是通过引用传递给函数的。这意味着原始数组被发送作为各种修改函数的参数,而不是它的副本。小心不要意外地做出不想要的修改。同样,当将数组赋给变量时,会创建一个新的引用,但数据不会被复制。所以例如:

julia> arr = [1,2,3] 
3-element Array{Int64,1}: 
 1 
 2 
 3 

julia> arr2 = arr 
3-element Array{Int64,1}: 
 1 
 2 
 3

现在我们从arr2中移除一个元素:

julia> pop!(arr2) 
3 

因此,arr2看起来是这样的:

julia> arr2 
2-element Array{Int64,1}: 
 1 
 2 

但我们的原始数组也被修改了:

julia> arr 
2-element Array{Int64,1}: 
 1 
 2 

arr赋给arr2并不会将arr的值复制到arr2中,它只创建了一个新的绑定(一个新的名称),指向原始的arr数组。要创建具有相同值的单独数组,我们需要使用copy函数:

julia> arr 
2-element Array{Int64,1}: 
 1 
 2 

julia> arr2 = copy(arr) 
2-element Array{Int64,1}: 
 1 
 2 

现在,如果我们从复制的数组中移除一个元素:

julia> pop!(arr2) 
2 

我们原始的数组没有改变:

julia> arr 
2-element Array{Int64,1}: 
 1 
 2

只有副本被修改了:

julia> arr2 
1-element Array{Int64,1}: 
 1 

集合推导

数组推导提供了一种非常强大的构建数组的方法。它类似于之前讨论的数组字面量表示法,但不同之处在于我们不是传递实际值,而是使用对可迭代对象的计算。

一个例子会使其更清楚:

julia> [x += 1 for x = 1:5] 
10-element Array{Int64,1}: 
  2 
  3 
  4 
  5 
  6

这可以读作——对于范围15内的每个元素x,计算x+1并将结果值放入数组中。

就像普通的数组字面量一样,我们可以约束类型:

julia> Float64[x+=1 for x = 1:5] 
5-element Array{Float64,1}: 
 2.0 
 3.0 
 4.0 
 5.0 
 6.0 

类似地,我们可以创建多维数组:

julia> [x += y for x = 1:5, y = 11:15] 
5×5 Array{Int64,2}: 
 12  13  14  15  16 
 13  14  15  16  17 
 14  15  16  17  18 
 15  16  17  18  19 
 16  17  18  19  20 

可以使用if关键字对集合进行过滤:

julia> [x += 1 for x = 1:10 if x/2 > 3] 
4-element Array{Int64,1}: 
  8 
  9 
 10 
 11 

在这种情况下,我们只保留了x/2大于3的值。

生成器

但是,当它们用于创建生成器时,集合的强大功能被激活了。生成器可以被迭代以按需产生值,而不是分配一个数组并在事先存储所有值。你将在下一秒看到这意味着什么。

生成器定义的方式与数组推导式相同,但没有方括号:

julia> (x+=1 for x = 1:10) 
Base.Generator{UnitRange{Int64},##41#42}(#41, 1:10) 

它们允许我们与可能无限大的集合一起工作。检查以下示例,我们想要打印出从一到一百万的数字,其立方小于或等于1_000

julia> for i in [x³ for x=1:1_000_000] 
           i >= 1_000 && break 
           println(i) 
end 
1 
8 
27 
64 
125 
216 
343 
512 
729 

这个计算使用了大量的资源,因为理解创建了一个包含 1 百万个项目的完整数组,尽管我们只迭代了它的前九个元素。

我们可以通过使用方便的@time构造函数来基准测试代码:

@time for i in [x³ for x=1:1_000_000] 
   i >= 1_000 && break 
   println(i) 
end 

0.035739 seconds (58.46 k allocations: 10.493 MiB)

超过 10 MB 的内存和* 60,000 次分配。与使用生成器相比:

@time for i in (x³ for x=1:1_000_000) 
   i >= 1_000 && break 
   println(i) 
end 

0.019681 seconds (16.63 k allocations: 898.414 KiB)  

不到 1 MB 和分配次数的四分之一。如果我们从 1 百万增加到 10 亿,差异将更加明显:

julia> @time for i in [x³ for x=1:1_000_000_000] 
          i >= 1_000 && break 
          println(i) 
       end 
1 
8 
27 
64 
125 
216 
343 
512 
729

 10.405833 seconds (58.48 k allocations: 7.453 GiB, 3.41% gc time) 

超过 10 秒和 7 GB 的内存使用!

另一方面,生成器几乎以恒定的时间运行:

julia> @time for i in (x³ for x=1:1_000_000_000) 
          i >= 1_000 && break 
          println(i) 
       end 
1 
8 
27 
64 
125 
216 
343 
512 
729 

  0.020068 seconds (16.63 k allocations: 897.945 KiB 

使用 Julia 进行数据探索分析

现在你已经很好地理解了 Julia 的基础知识,我们可以将这个知识应用到我们的第一个项目中。我们将首先通过数据探索分析EDA)来应用爱丽丝花数据集。

如果你已经对数据分析有经验,你可能之前已经使用过爱丽丝花数据集。如果是这样,那太好了!你将熟悉数据以及在你(之前)选择的语言中如何做事,现在可以专注于 Julia 的方式。

相反,如果你第一次听说爱丽丝花数据集,无需担心。这个数据集被认为是数据科学的Hello World——我们将使用 Julia 强大的工具箱来仔细研究它。享受吧!

爱丽丝花数据集

也称为费舍尔的爱丽丝花数据集,它最初由英国统计学家和生物学家罗纳德·费舍尔在 1936 年介绍。该数据集由三个品种的爱丽丝花(Iris setosa、Iris virginica 和 Iris versicolor)的 50 个样本组成。有时它被称为安德森的爱丽丝花数据集,因为埃德加·安德森收集了这些数据。测量了四个特征——萼片和花瓣的长度和宽度(以厘米为单位)。

使用RDatasets

寻找用于学习、教学和统计软件开发的高质量数据可能具有挑战性。这就是为什么该行业实际上已经标准化了超过 10,000 个高质量数据集的使用。这些数据集最初是与统计软件环境 R 一起分发的。因此,它们被恰当地命名为RDatasets

爱丽丝花数据集是本集合的一部分。有多种方式可以下载它,但最方便的方式是通过RDatasets包。这个包为 Julia 用户提供了方便的方式来实验 R 中大多数标准数据集,或者包含在 R 最受欢迎的包中。听起来很棒;让我们添加它。

首先,切换到包管理模式:

 julia> ]
    pkg> add RDatasets 

一旦添加了包,让我们告诉 Julia 我们想要使用它:

 julia> using RDatasets 

我们可以通过调用 RDatasets.datasets() 来查看包含的数据集。它返回一个包含 RDatasets 中所有 700 多个数据集的列表。它包括数据包的详细信息、数据集的名称、标题(或信息)、行数和列数。以下是前 20 行:

julia> RDatasets.datasets() 

输出如下:

图片

你可以看到数据集是 Package 的一部分——我们可以用它来过滤。Iris 花数据集是 datasets 包的一部分。

现在我们只需要加载数据:

julia> iris = dataset("datasets", "iris") 

输出如下:

图片

返回值是一个包含 150 行和五列的 DataFrame 对象——SepalLength(花萼长度)、SepalWidth(花萼宽度)、PetalLength(花瓣长度)、PetalWidth(花瓣宽度)和 Species(物种),以及一个自动添加的名为 Row 的 id 列。

Dataframes 是 Julia 处理表格数据的 de facto 标准。它们是 Julia 数据分析工具集的关键部分,我们将在下一章中详细讨论它们。现在,只需说,正如你在前面的例子中看到的那样,它代表了一种类似于表格或电子表格的数据结构。

你可以使用以下方式编程地检索列名:

julia> names(iris) 
5-element Array{Symbol,1}: 
 :SepalLength 
 :SepalWidth 
 :PetalLength 
 :PetalWidth 
 :Species 

要检查大小,请使用以下方法:

julia> size(iris) 
(150, 5)

结果是一个与行数和列数相匹配的元组 (rows, cols)。是的,正如已经确立的,150 行跨越 5 列。

让我们看看数据:

julia> head(iris) 

输出如下:

图片

head 函数显示前六行。可选地,它接受第二个参数来指定行数:head(iris, 10)。还有一个它的双胞胎函数 tail(),它将显示 DataFrame 的底部行:

julia> tail(iris, 10) 

输出如下:

图片

关于数据集中存在的物种,我们在头部行中看到 setosa,在底部看到 virginica。然而,根据数据的描述,我们应该有三个物种。让我们按 Species 分组请求行数:

julia> by(iris, :Species, nrow)

输出如下:

图片

by 函数接受三个参数——数据集、列名和一个分组函数——在这个例子中,nrow,它计算行数。我们可以看到第三种物种是 versicolor,对于每种物种,我们都有 50 条记录。

我敢肯定你一定想知道,在前面的例子中,为什么列名前面有一个冒号 ":"。这是一个 Symbol。当我们学习到元编程时,我们将更详细地讨论符号。现在,你只需将符号视为标识符或标签即可。

使用简单统计来更好地理解我们的数据

现在,我们已经清楚地了解了数据的结构以及集合中包含的内容,我们可以通过查看一些基本统计信息来更好地理解。

为了让我们开始,让我们调用 describe 函数:

julia> describe(iris)

输出如下:

此函数总结了 iris DataFrame 的列。如果列包含数值数据(如 SepalLength),它将计算最小值、中位数、*均值和最大值。还包括缺失值和唯一值的数量。最后一列报告存储在行中的数据类型。

一些其他统计信息也是可用的,包括第 25 百分位和第 75 百分位,以及第一个和最后一个值。我们可以通过传递一个额外的 stats 参数来请求它们,该参数是一个符号数组的格式:

julia> describe(iris, stats=[:q25, :q75, :first, :last]) 

输出如下:

接受任何组合的统计标签。这些都是所有选项——:mean:std:min:q25:median:q75:max:eltype:nunique:first:last:nmissing

为了获取所有统计信息,接受特殊的 :all 值:

julia> describe(iris, stats=:all)

输出如下:

我们也可以通过使用 Julia 的 Statistics 包单独计算这些值。例如,要计算 SepalLength 列的*均值,我们将执行以下操作:

julia> using Statistics 
julia> mean(iris[:SepalLength]) 
5.843333333333334 

在这个例子中,我们使用 iris[:SepalLength] 来选择整个列。结果,毫不意外,与相应的 describe() 返回值相同。

以类似的方式,我们可以计算 median()

julia> median(iris[:SepalLength]) 
5.8 

还有更多(很多)内容,例如,例如,标准差 std()

julia> std(iris[:SepalLength]) 
0.828066127977863 

或者,我们可以使用 Statistics 包中的另一个函数 cor(),在简单的脚本中帮助我们了解值之间的相关性:

julia> for x in names(iris)[1:end-1]    
        for y in names(iris)[1:end-1] 
          println("$x \t $y \t $(cor(iris[x], iris[y]))") 
        end 
        println("-------------------------------------------") 
      end

执行此代码片段将产生以下输出:

SepalLength       SepalLength    1.0 
SepalLength       SepalWidth     -0.11756978413300191 
SepalLength       PetalLength    0.8717537758865831 
SepalLength       PetalWidth     0.8179411262715759 
------------------------------------------------------------ 
SepalWidth         SepalLength    -0.11756978413300191 
SepalWidth         SepalWidth     1.0 
SepalWidth         PetalLength    -0.42844010433053953 
SepalWidth         PetalWidth     -0.3661259325364388 
------------------------------------------------------------ 
PetalLength       SepalLength    0.8717537758865831 
PetalLength       SepalWidth     -0.42844010433053953 
PetalLength       PetalLength    1.0 
PetalLength       PetalWidth     0.9628654314027963 
------------------------------------------------------------ 
PetalWidth         SepalLength    0.8179411262715759 
PetalWidth         SepalWidth     -0.3661259325364388 
PetalWidth         PetalLength    0.9628654314027963 
PetalWidth         PetalWidth     1.0 
------------------------------------------------------------ 

脚本遍历数据集的每一列,除了 Species(最后一列,不是数值类型),并生成一个基本的关联表。该表显示 SepalLengthPetalLength(87.17%)、SepalLengthPetalWidth(81.79%)、以及 PetalLengthPetalWidth(96.28%)之间存在强烈的正相关。SepalLengthSepalWidth 之间没有强烈的关联。

我们可以使用相同的脚本,但这次使用 cov() 函数来计算数据集中值的协方差:

julia> for x in names(iris)[1:end-1] 
         for y in names(iris)[1:end-1] 
           println("$x \t $y \t $(cov(iris[x], iris[y]))") 
         end 
         println("--------------------------------------------") 
       end 

此代码将生成以下输出:

SepalLength       SepalLength    0.6856935123042507 
SepalLength       SepalWidth     -0.04243400447427293 
SepalLength       PetalLength    1.2743154362416105 
SepalLength       PetalWidth     0.5162706935123043 
------------------------------------------------------- 
SepalWidth         SepalLength    -0.04243400447427293 
SepalWidth         SepalWidth     0.189979418344519 
SepalWidth         PetalLength    -0.3296563758389262 
SepalWidth         PetalWidth     -0.12163937360178968 
------------------------------------------------------- 
PetalLength       SepalLength    1.2743154362416105 
PetalLength       SepalWidth     -0.3296563758389262 
PetalLength       PetalLength    3.1162778523489933 
PetalLength       PetalWidth     1.2956093959731543 
------------------------------------------------------- 
PetalWidth         SepalLength    0.5162706935123043 
PetalWidth         SepalWidth     -0.12163937360178968 
PetalWidth         PetalLength    1.2956093959731543 
PetalWidth         PetalWidth     0.5810062639821031 
------------------------------------------------------- 

输出说明了 SepalLengthPetalLengthPetalWidth 正相关,而与 SepalWidth 负相关。SepalWidth 与所有其他值负相关。

接下来,如果我们想要一个随机数据样本,我们可以这样请求:

julia> rand(iris[:SepalLength]) 
7.4

可选地,我们可以传递要采样的值的数量:

julia> rand(iris[:SepalLength], 5) 
5-element Array{Float64,1}: 
 6.9 
 5.8 
 6.7 
 5.0 
 5.6 

我们可以使用以下方法将某一列转换为数组:

julia> sepallength = Array(iris[:SepalLength]) 
150-element Array{Float64,1}: 
 5.1 
 4.9 
 4.7 
 4.6 
 5.0 
 # ... output truncated ... 

或者,我们可以将整个 DataFrame 转换为矩阵:

julia> irisarr = convert(Array, iris[:,:]) 
150×5 Array{Any,2}: 
 5.1  3.5  1.4  0.2  CategoricalString{UInt8} "setosa"    
 4.9  3.0  1.4  0.2  CategoricalString{UInt8} "setosa"    
 4.7  3.2  1.3  0.2  CategoricalString{UInt8} "setosa"    
 4.6  3.1  1.5  0.2  CategoricalString{UInt8} "setosa"    
 5.0  3.6  1.4  0.2  CategoricalString{UInt8} "setosa"   
 # ... output truncated ... 

可视化鸢尾花数据

可视化是探索性数据分析中的强大工具,帮助我们识别仅通过查看数字难以发现的模式。Julia 提供了访问一些出色的绘图包的途径,这些包非常容易设置和使用。

我们将通过使用 Gadfly 创建的一些图表来举例说明。

我们将首先通过pkg> add "Gadfly"添加 Gadfly,然后继续使用julia> using Gadfly。这将使 Gadfly 的plot()方法生效。现在,让我们找到一些有趣的数据来进行可视化。

在上一节中,我们已经确定SepalLengthPetalLength之间存在强烈的协变关系。让我们绘制这些数据:

julia> plot(iris, x=:SepalLength, y=:PetalLength, color=:Species) 

在撰写本文时,Gadfly 对 Julia v1 的支持仍然不完整。如果情况仍然如此,可以使用不稳定但可工作的 Gadfly 版本安装——pkg> add Compose#master, Gadfly#master, Hexagon

执行plot()函数将生成以下图形:

果然,该图将表明对于 Iris versicolor 和 Iris virginica,SepalLengthPetalLength是共同变化的。对于 Iris setosa,这并不那么明显,因为PetalLength基本保持不变,而萼片长度在增长。

箱线图将确认相同的结果;Iris setosa 的萼片长度变化很小:

julia> plot(iris, x=:Species, y=:PetalLength, Geom.boxplot)

我们绘制值的样子如下:

我有一种感觉,直方图将更好地说明PetalLength的分布:

julia> plot(iris, x=:PetalLength, color=:Species, Geom.histogram) 

使用PetalLength生成直方图会产生以下结果:

如果我们将PetalWidth值可视化为直方图,我们会注意到类似的模式:

julia> plot(iris, x=:PetalWidth, color=:Species, Geom.histogram)

输出如下:

绘制三种物种的花瓣宽度和高度图,现在应该能强烈表明,例如,我们可以根据这两个值成功地将鸢尾花属的 Iris setosa 进行分类:

julia> plot(iris, x=:PetalWidth, y=:PetalLength, color=:Species)

输出如下:

加载和保存我们的数据

Julia 自带了出色的读取和存储数据的工具。鉴于其专注于数据科学和科学计算,对表格文件格式(CSV,TSV)的支持是一流的。

让我们从我们的初始数据集中提取一些数据,并使用它来练习从各种后端进行持久化和检索。

我们可以通过定义相应的列和行来引用DataFrame的某个部分。例如,我们可以定义一个新的DataFrame,它仅由PetalLengthPetalWidth列以及前三个行组成:

julia> iris[1:3, [:PetalLength, :PetalWidth]] 
3×2 DataFrames.DataFrame 
│ Row │ PetalLength │ PetalWidth │ 
├─────┼─────────────┼────────────┤ 
│ 1   │ 1.4         │ 0.2        │ 
│ 2   │ 1.4         │ 0.2        │ 
│ 3   │ 1.3         │ 0.2        │ 

通用索引符号是dataframe[rows, cols],其中rows可以是数字、范围或boolean值的Array,其中true表示该行应被包含:

julia> iris[trues(150), [:PetalLength, :PetalWidth]] 
150 rows since trues(150) constructs an array of 150 elements that are all initialized as true. The same logic applies to cols, with the added benefit that they can also be accessed by name.

带着这些知识,让我们从原始数据集中抽取一个样本。它将包括大约 10% 的初始数据,以及 PetalLengthPetalWidthSpecies 列:

julia> test_data = iris[rand(150) .<= 0.1, [:PetalLength, :PetalWidth, :Species]] 
10×3 DataFrames.DataFrame 
│ Row │ PetalLength │ PetalWidth │ Species      │ 
├─────┼─────────────┼────────────┼──────────────┤ 
│ 1   │ 1.1         │ 0.1        │ "setosa"     │ 
│ 2   │ 1.9         │ 0.4        │ "setosa"     │ 
│ 3   │ 4.6         │ 1.3        │ "versicolor" │ 
│ 4   │ 5.0         │ 1.7        │ "versicolor" │ 
│ 5   │ 3.7         │ 1.0        │ "versicolor" │ 
│ 6   │ 4.7         │ 1.5        │ "versicolor" │ 
│ 7   │ 4.6         │ 1.4        │ "versicolor" │ 
│ 8   │ 6.1         │ 2.5        │ "virginica"  │ 
│ 9   │ 6.9         │ 2.3        │ "virginica"  │ 
│ 10  │ 6.7         │ 2.0        │ "virginica"  │ 

这里发生了什么?这段代码的秘密在于 rand(150) .<= 0.1。它做了很多事情——首先,它生成一个介于 0 和 1 之间的随机 Float 值数组;然后,它逐元素比较数组与 0.1(代表 1 的 10%);最后,生成的 Boolean 数组用于从数据集中过滤出相应的行。Julia 的强大和简洁真的令人印象深刻!

在我的情况下,结果是包含前面 10 行的 DataFrame,但你的数据可能会有所不同,因为我们正在选择随机行(而且你也不一定有 exactly 10 行)。

使用表格文件格式进行保存和加载

我们可以使用 CSV 包轻松地将这些数据保存到表格文件格式(CSV、TSV 等之一)的文件中。我们首先需要添加它,然后调用 write 方法:

pkg> add CSV 
julia> using CSV 
julia> CSV.write("test_data.csv", test_data)  

同样容易,我们可以使用相应的 CSV.read 函数从表格文件格式中读取数据:

julia> td = CSV.read("test_data.csv") 
10×3 DataFrames.DataFrame 
│ Row │ PetalLength │ PetalWidth │ Species      │ 
├─────┼─────────────┼────────────┼──────────────┤ 
│ 1   │ 1.1         │ 0.1        │ "setosa"     │ 
│ 2   │ 1.9         │ 0.4        │ "setosa"     │ 
│ 3   │ 4.6         │ 1.3        │ "versicolor" │ 
│ 4   │ 5.0         │ 1.7        │ "versicolor" │ 
│ 5   │ 3.7         │ 1.0        │ "versicolor" │ 
│ 6   │ 4.7         │ 1.5        │ "versicolor" │ 
│ 7   │ 4.6         │ 1.4        │ "versicolor" │ 
│ 8   │ 6.1         │ 2.5        │ "virginica"  │ 
│ 9   │ 6.9         │ 2.3        │ "virginica"  │ 
│ 10  │ 6.7         │ 2.0        │ "virginica"  │ 

仅指定文件扩展名就足以让 Julia 理解如何处理文档(CSV、TSV),无论是写入还是读取。

使用 Feather 文件

Feather 是一种专门为存储数据框而设计的二进制文件格式。它快速、轻量级且语言无关。该项目最初启动是为了使 R 和 Python 之间交换数据框成为可能。很快,其他语言也添加了对它的支持,包括 Julia。

对 Feather 文件的支持不是默认提供的,但可以通过同名的包获得。让我们继续添加它并将其纳入作用域:

pkg> add Feather  
julia> using Feather

现在,保存我们的 DataFrame 只需调用 Feather.write

julia> Feather.write("test_data.feather", test_data) 

接下来,让我们尝试反向操作并重新加载我们的 Feather 文件。我们将使用对应的 read 函数:

julia> Feather.read("test_data.feather") 
10×3 DataFrames.DataFrame 
│ Row │ PetalLength │ PetalWidth │ Species      │ 
├─────┼─────────────┼────────────┼──────────────┤ 
│ 1   │ 1.1         │ 0.1        │ "setosa"     │ 
│ 2   │ 1.9         │ 0.4        │ "setosa"     │ 
│ 3   │ 4.6         │ 1.3        │ "versicolor" │ 
│ 4   │ 5.0         │ 1.7        │ "versicolor" │ 
│ 5   │ 3.7         │ 1.0        │ "versicolor" │ 
│ 6   │ 4.7         │ 1.5        │ "versicolor" │ 
│ 7   │ 4.6         │ 1.4        │ "versicolor" │ 
│ 8   │ 6.1         │ 2.5        │ "virginica"  │ 
│ 9   │ 6.9         │ 2.3        │ "virginica"  │ 
│ 10  │ 6.7         │ 2.0        │ "virginica"  │ 

是的,这正是我们的样本数据!

为了与其他语言提供兼容性,Feather 格式对列的数据类型施加了一些限制。你可以在包的官方文档中了解更多关于 Feather 的信息:juliadata.github.io/Feather.jl/latest/index.html

使用 MongoDB 进行保存和加载

在关闭这一章之前,让我们看看如何使用 NoSQL 后端来持久化和检索我们的数据。别担心,我们将在接下来的章节中广泛介绍与关系型数据库的交互。

为了继续本章内容,你需要一个可工作的 MongoDB 安装。你可以从官方网站下载并安装适用于你操作系统的正确版本,网址为www.mongodb.com/download-center?jmp=nav#community。我将使用一个通过 Docker 的 Kitematic(可在github.com/docker/kitematic/releases下载)安装并启动的 Docker 镜像。

接下来,我们需要确保添加Mongo包。该包还依赖于LibBSON,它将自动添加。LibBSON用于处理BSON,即二进制 JSON,类似于 JSON 的文档的二进制编码序列化。在此期间,让我们也添加JSON包;我们将需要它。我相信你现在知道如何做——如果不的话,这里有一个提醒:

pkg> add Mongo, JSON 

在撰写本文时,Mongo.jl 对 Julia v1 的支持仍在进行中。此代码使用 Julia v0.6 进行了测试。

简单!让我们让 Julia 知道我们将使用所有这些包:

julia> using Mongo, LibBSON, JSON 

现在我们已经准备好连接到 MongoDB:

julia> client = MongoClient() 

一旦成功连接,我们就可以在db数据库中引用dataframes集合:

julia> storage = MongoCollection(client, "db", "dataframes")  

Julia 的 MongoDB 接口使用字典(在 Julia 中称为Dict的数据结构)与服务器通信。我们将在下一章中更详细地了解dicts。现在,我们只需要将我们的DataFrame转换为这样的Dict。最简单的方法是使用JSON包按顺序序列化和反序列化DataFrame。它生成一个很好的结构,我们可以稍后使用它来重建我们的DataFrame

julia> datadict = JSON.parse(JSON.json(test_data)) 

提前思考,为了使未来的数据检索更简单,让我们在我们的字典中添加一个标识符:

julia> datadict["id"] = "iris_test_data" 

现在我们可以将其插入 Mongo 数据库中:

julia> insert(storage, datadict) 

为了检索它,我们只需使用之前配置的“id”字段查询 Mongo 数据库:

Julia> data_from_mongo = first(find(storage, query("id" => "iris_test_data"))) 

我们得到一个BSONObject,我们需要将其转换回DataFrame。别担心,这很简单。首先,我们创建一个空的DataFrame

julia> df_from_mongo = DataFrame() 
0×0 DataFrames.DataFrame 

然后我们使用从 Mongo 检索到的数据填充它:

for i in 1:length(data_from_mongo["columns"]) 
  df_from_mongo[Symbol(data_from_mongo["colindex"]["names"][i])] =  
Array(data_from_mongo["columns"][i]) 
end 
julia> df_from_mongo 
10×3 DataFrames.DataFrame 
│ Row │ PetalLength │ PetalWidth │ Species      │ 
├─────┼─────────────┼────────────┼──────────────┤ 
│ 1   │ 1.1         │ 0.1        │ "setosa"     │ 
│ 2   │ 1.9         │ 0.4        │ "setosa"     │ 
│ 3   │ 4.6         │ 1.3        │ "versicolor" │ 
│ 4   │ 5.0         │ 1.7        │ "versicolor" │ 
│ 5   │ 3.7         │ 1.0        │ "versicolor" │ 
│ 6   │ 4.7         │ 1.5        │ "versicolor" │ 
│ 7   │ 4.6         │ 1.4        │ "versicolor" │ 
│ 8   │ 6.1         │ 2.5        │ "virginica"  │ 
│ 9   │ 6.9         │ 2.3        │ "virginica"  │ 
│ 10  │ 6.7         │ 2.0        │ "virginica"  │ 

就这样!我们的数据已经重新加载到DataFrame中。

摘要

Julia 直观的语法使得学习曲线变得*缓。可选的类型和丰富的缩写构造函数使得代码可读、无噪声,而大量的第三方包使得访问、操作、可视化、绘图和保存数据变得轻而易举。

只需学习 Julia 的基本数据结构和一些相关函数,再加上其强大的数据处理工具集,我们就能够实现高效的数据分析工作流程,并从 Iris 花朵数据集中提取有价值的见解。这正是我们使用 Julia 进行高效探索性数据分析所需的一切。

在下一章中,我们将继续我们的旅程,学习如何构建一个网络爬虫。网络挖掘,即从网络中提取信息的过程,是数据挖掘的重要组成部分,也是数据获取的关键环节。在构建网络挖掘软件时,Julia 是一个极佳的选择,这不仅因为它内置的性能和快速原型设计功能,还因为其提供了覆盖从 HTTP 客户端到 DOM 解析再到文本分析的强大库。

第三章:设置 Wiki 游戏

我希望你现在对 Julia 感到兴奋。友好、表达丰富且直观的语法,强大的 read-eval-print 循环REPL),出色的性能,以及内置和第三方库的丰富性,对于数据科学(尤其是编程)来说是一个颠覆性的组合。事实上,仅仅在两个入门章节中,我们就能够掌握语言的基础,并配置一个足够强大的数据科学环境来分析 Iris 数据集,这相当令人惊讶——恭喜,我们做得很好!

但我们实际上才刚刚开始。我们奠定的基础现在足够强大,可以让我们使用 Julia 开发几乎任何类型的程序。难以置信吗?好吧,这里是证据——在接下来的三个章节中,我们将使用 Julia 开发一个基于网页的游戏!

它将遵循互联网上著名的 六度分隔 Wikipedia 的叙事。如果你从未听说过它,其想法是任何两篇维基百科文章都可以通过页面上的链接连接起来,只需点击六次或更少。它也被称为 六度分隔

如果你在想这与 Julia 有什么关系,这是一个有趣的理由来学习数据挖掘和网页抓取,并且更多地了解这门语言,将我们新获得的知识应用到构建网页应用中。

在本章中,我们将奠定网页抓取的基础。我们将探讨在客户端-服务器架构中如何在网络上发出请求,以及如何使用 HTTP 包抓取网页。我们将学习关于 HTML 文档、HTML 和 CSS 选择器,以及 Gumbo,Julia 的 HTML 解析器。在这个过程中,我们将在 REPL 中实验更多代码,并了解语言的其他关键特性,如字典、错误处理、函数和条件语句。我们还将设置我们的第一个 Julia 项目。

本章我们将涵盖以下主题:

  • 网页抓取是什么以及它是如何用于数据采集的

  • 如何使用 Julia 发出请求和抓取网页

  • 了解 Pair 类型

  • 了解字典,这是 Julia 中更灵活的数据结构之一

  • 异常处理,帮助我们捕获代码中的错误

  • 函数,Julia 的基本构建块和最重要的代码单元之一——我们将学习如何定义和使用它们来创建可重用、模块化的代码

  • 一些有用的 Julia 技巧,例如管道操作符和短路评估

  • 使用 Pkg 设置 Julia 项目

技术要求

Julia 包生态系统正在持续发展中,并且每天都有新的包版本发布。大多数时候,这是一个好消息,因为新版本带来了新功能和错误修复。然而,由于许多包仍然处于测试版(版本 0.x),任何新版本都可能引入破坏性更改。因此,书中展示的代码可能会停止工作。为了确保您的代码能够产生与书中描述相同的结果,建议使用相同的包版本。以下是本章使用的外部包及其具体版本:

Gumbo@v0.5.1
HTTP@v0.7.1
IJulia@v1.14.1
OrderedCollections@v1.0.2

为了安装特定版本的包,您需要运行:

pkg> add PackageName@vX.Y.Z 

例如:

pkg> add IJulia@v1.14.1

或者,您也可以通过下载章节提供的Project.toml文件并使用pkg>实例化来安装所有使用的包:

julia> download("https://raw.githubusercontent.com/PacktPublishing/Julia-Programming-Projects/master/Chapter03/Project.toml", "Project.toml")
pkg> activate . 
pkg> instantiate

通过网络爬虫进行数据采集

使用软件从网页中提取数据的技术称为网络爬虫。它是数据采集的重要组件,通常通过称为网络爬虫的程序实现。数据采集或数据挖掘是一种有用的技术,常用于数据科学工作流程中,从互联网上收集信息,通常是从网站(而不是 API)上,然后使用各种算法对数据进行处理,以达到不同的目的。

在非常高的层面上,这个过程涉及对网页发出请求,获取其内容,解析其结构,然后提取所需的信息。这可能包括图像、文本段落或包含股票信息和价格的表格数据,例如——几乎任何在网页上存在的内容。如果内容分布在多个网页上,爬虫还会提取链接,并自动跟随它们以拉取其余页面,反复应用相同的爬取过程。

网络爬虫最常见的使用是用于网络索引,如 Google 或 Bing 等搜索引擎所做的那样。在线价格监控和价格比较、个人数据挖掘(或联系爬取)、在线声誉系统,以及产品评论*台,都是网络爬虫的其他常见用例。

网络工作原理——快速入门

在过去的十年里,互联网已经成为我们生活的一个基本组成部分。我们中的大多数人都广泛地使用它来获取大量的信息,日复一日。无论是搜索“rambunctious”(喧闹且缺乏自律或纪律),在社交网络上与朋友保持联系,在 Instagram 上查看最新的美食餐厅,在 Netflix 上观看热门电影,还是阅读关于 Attitogon(多哥的一个地方,那里的人们练习巫毒教)的维基百科条目——所有这些,尽管性质不同,但基本上都以相同的方式运作。

一个连接到互联网的设备,无论是使用 Wi-Fi 的计算机还是连接到移动数据网络的智能手机,以及一个用于访问网络的程序(通常是一个 Web 浏览器,如 Chrome 或 Firefox,也可以是专门的程序,如 Facebook 或 Netflix 的移动应用),代表客户端。在另一端是服务器——一个存储信息的计算机,无论是以网页、视频还是整个 Web 应用的形式。

当客户端想要访问服务器上的信息时,它会发起一个请求。如果服务器确定客户端有权访问资源,信息的一个副本将从服务器下载到客户端,以便显示。

发送 HTTP 请求

超文本传输协议HTTP)是一种用于在网络上传输文档的通信协议。它是为了在 Web 浏览器和 Web 服务器之间进行通信而设计的。HTTP 实现了标准的客户端-服务器模型,其中客户端打开一个连接并发出请求,然后等待响应。

了解 HTTP 方法

HTTP 定义了一组请求方法,用于指示对给定资源要执行的操作。最常见的方法是GET,它的目的是从服务器检索数据。当通过链接在互联网上导航时使用。POST方法请求服务器接受一个包含的数据有效负载,通常是提交网页表单的结果。还有一些其他方法,包括HEADPUTDELETEPATCH等——但它们使用较少,并且客户端和 Web 服务器支持较少。由于我们不需要它们进行我们的网络爬虫,所以不会涉及这些。

如果你对它们感兴趣,可以在developer.mozilla.org/en-US/docs/Web/HTTP/Methods上阅读有关内容。

理解 HTTPS

HTTP 安全HTTPS)基本上是在加密连接上运行的 HTTP。它最初是一种主要用于在互联网上处理支付和传输敏感企业信息的替代协议。但*年来,它已经开始得到广泛的使用,主要公司推动在互联网上用 HTTPS 替换普通的 HTTP 连接。在我们的讨论中,HTTP 和 HTTPS 可以互换使用。

理解 HTML 文档

为了从获取的网页中提取数据,我们需要隔离和操作包含所需信息的结构元素。这就是为什么在执行网络爬取时,对网页通用结构的了解很有帮助。如果你之前进行过网络爬取,可能使用的是不同的编程语言,或者如果你对 HTML 文档了解足够多,可以自由跳过这一部分。另一方面,如果你是新手或者只是需要快速复习,请继续阅读。

超文本标记语言(HTML)是创建网页和网页应用的黄金标准。HTML 与 HTTP 协议相辅相成,该协议用于在互联网上传输 HTML 文档。

HTML 页面的构建块是HTML 元素。它们提供了网页的内容和结构。它们可以通过嵌套来定义彼此之间的复杂关系(如父元素、子元素、兄弟元素、祖先元素等)。HTML 元素通过标签表示,标签写在大括号之间(<tag>...</tag>)。官方 W3C 规范定义了大量的此类标签,代表从标题和段落到列表、表单、链接、图片、引语等一切内容。

为了让您有一个概念,以下是如何在 Julia 的维基百科页面en.wikipedia.org/wiki/Julia_(programming_language)上用 HTML 表示主要标题的示例:

<h1>Julia (programming language)</h1> 

在现代浏览器中,这段 HTML 代码会呈现如下:

一个更详细的例子可以展示一个嵌套结构,如下所示:

<div> 
    <h2>Language features</h2> 
    <p>According to the official website, the main features of the language are:</p> 
    <ul> 
           <li>Multiple dispatch</li> 
           <li>Dynamic type sytem</li> 
           <li>Good performance</li> 
    </ul> 
</div> 
<h2>), a paragraph of text (<p>), and an unordered list (<ul>), with three list items (<li>), all within a page section (<div>):

HTML 选择器

HTML 的目的是提供内容和结构。这就是我们传达任何类型信息所需的一切,无论信息多么复杂。然而,随着计算机和网页浏览器的变得更加强大,以及网页的使用变得更加普遍,用户和开发者想要更多。他们要求扩展 HTML,以便包括美丽的格式(设计)和丰富的行为(交互性)。

正因如此,层叠样式表(CSS)被创建出来——一种定义 HTML 文档设计的样式语言。此外,JavaScript 也成为了客户端编程语言的首选,为网页增加了交互性。

CSS 和 JavaScript 提供的样式规则和交互功能与定义良好的 HTML 元素相关联。也就是说,样式和交互必须明确针对相关的 HTML 文档中的元素。例如,一个 CSS 规则可以针对页面的主要标题——或者一个 JavaScript 验证规则可以针对登录表单中的文本输入。如果您将网页视为一个结构化的 HTML 元素集合,这种针对是通过选择(子集合)元素来实现的。

选择元素可以通过简单地识别 HTML 标签的类型和结构(层次结构)来完成。在先前的例子中,我们查看如何表示 Julia 的功能列表时,我们可以通过指定一个层次结构如div > ul > li来选择所有列表项(<li>元素),这表示所有嵌套在ul元素中的li元素,而ul元素又嵌套在div元素中。这些被称为HTML 选择器

然而,这种方法有其局限性。一方面,当处理大型、复杂且深度嵌套的 HTML 文档时,我们必须处理同样复杂的层次结构,这是一项繁琐且容易出错的任务。另一方面,这种方法可能不足以提供足够的特定性,使我们能够选择我们想要的目标元素。例如,在相同的 Julia 维基百科页面上,我们如何区分功能列表和外部链接列表?它们都有相似的结构。

Julia 维基百科页面上的 外部链接 列表看起来是这样的:

图片

语言功能 部分有类似的结构:

图片

两个 HTML 元素在结构上相同的事实使得单独选择语言功能列表项变得困难。

学习 HTML 属性

这就是 HTML 属性发挥作用的地方。这些是键值对,它们增强了 HTML 标签,提供了额外信息。例如,为了定义一个链接,我们将使用 <a> 标签——<a>This is a link</a>

但显然,这还不够。如果这是一个链接,它链接到什么?作为开发者,我们需要提供有关链接位置的一些额外信息。这是通过添加带有相应值的 href 属性来完成的:

<a href="https://julialang.org/">This is a link to Julia's home page</a>

哎呀,现在我们说到点子上了!一个超级方便的链接到 Julia 的主页。

通常,所有属性都可以在选择 HTML 元素时使用。但并非所有属性都同样有用。其中最重要的可能是 id 属性。它允许我们为元素分配一个唯一的标识符,然后以非常高效的方式引用它。另一个重要的属性是 class,它被广泛用于 CSS 样式规则。

这就是我们之前的例子添加额外属性后的样子:

<a href="https://julialang.org/" id="julia_link" class="external_link">This is a link to Julia's home page</a>

学习 CSS 和 JavaScript 选择器

从历史上看,JavaScript 最初使用基于 id 属性和 HTML 元素(标签)名称的选择器。后来,CSS 规范带来了一组更强大的选择器,不仅包括 classid 和标签,还包括属性及其值、元素的状态(如 focuseddisabled),以及更具体的元素层次结构,它考虑了关系。

这里有一些可以用来定位之前讨论的 <a> 标签的 CSS 选择器示例:

  • #julia_linkid 属性的选择器(#

  • .external_linkclass 属性(.)的选择器

  • a<a> 标签的选择器

  • a[href*="julialang.org"] 将选择所有具有包含 "julialang.org"href 属性的 <a> 标签

你可以在developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors了解更多关于 CSS 选择器的信息。这个资源值得保留在身边,因为网络爬虫在很大程度上依赖于 CSS 选择器,正如我们将在下一章中看到的。

理解链接的结构

在技术术语中被称为统一资源定位符URLs)的链接,是一系列字符,它们唯一地标识了互联网上的资源。它们非正式地被称为网页地址。有时你可能看到它们被称为统一资源标识符URIs)。

在我们之前的例子中,Julia 的维基百科网页可以通过 URL en.wikipedia.org/wiki/Julia_(programming_language) 访问。这个 URL 指的是资源 /wiki/Julia_(programming_language),其表示形式,作为一个 HTML 文档,可以通过 HTTPS 协议(https:)从域名是 wikipedia.org 的网络主机请求。(哇,这听起来很复杂,但现在你可以理解请求互联网上网页的过程是多么复杂了)。

因此,一个常见的 URL 可以分解为以下部分——scheme://host/path?query#fragment

例如,如果我们查看en.wikipedia.org/wiki/Julia_(programming_language)?uselang=en#Interaction,我们有https作为schemeen.wikipedia.org作为host/wiki/Julia_(programming_language)作为path?uselang=en作为query,最后,#Interaction作为fragment

从 Julia 访问互联网

现在你已经很好地理解了如何通过客户端-服务器交互在互联网上访问网页,让我们看看我们如何使用 Julia 来实现这一点。

最常见的网络客户端是网络浏览器——如 Chrome 或 Firefox 这样的应用程序。然而,这些是为人类用户设计的,它们使用花哨的样式 UI 和复杂的交互来渲染网页。虽然可以通过网络浏览器手动进行网络爬取,但最有效和可扩展的方式是通过完全自动化的、软件驱动的流程。尽管网络浏览器可以被自动化(例如使用来自 www.seleniumhq.org 的 Selenium),但这是一项更困难、更容易出错且资源密集的任务。对于大多数用例,首选的方法是使用专门的 HTTP 客户端。

使用 HTTP 包进行请求

Pkg,Julia 的内置包管理器,提供了对优秀的 HTTP 包的访问。它暴露了构建网络客户端和服务器的高级功能——我们将广泛使用它。

正如你已经习惯的,额外的功能只需两个命令——pkg> add HTTPjulia> using HTTP

回想一下上一节关于 HTTP 方法的讨论;最重要的方法是 GET,用于从服务器请求资源,以及 POST,它将数据有效负载发送到服务器并接受响应。HTTP 包暴露了一组匹配的函数——我们可以访问 HTTP.getHTTP.postHTTP.deleteHTTP.put 等等。

假设我们想要请求朱莉娅的维基百科页面。我们需要的只是页面的 URL 和 HTTP.get 方法:

julia> HTTP.get("https://en.wikipedia.org/wiki/Julia_(programming_language)") 

结果将是一个 Response 对象,它代表了朱莉娅的维基百科页面及其所有细节。REPL 显示了头部和响应主体的前几行,其余部分被截断:

截图显示了我们所接收的 HTTP.Messages.Response 对象的详细信息——HTTP 头部的列表和响应主体的第一部分。让我们确保我们将其保存在变量中,以便稍后引用。记住,Julia 将上一次计算的结果暂时存储在 ans REPL 变量中,所以让我们从那里获取:

julia> resp = ans 

处理 HTTP 响应

在接收和处理请求后,服务器会发送一个 HTTP 响应消息。这些消息具有标准化的结构。它们包含大量信息,其中最重要的部分是状态码、头信息和主体。

HTTP 状态码

状态码是一个三位整数,其中第一位数字表示类别,而接下来的两位数字用于定义子类别。它们如下:

  • 1XX - 信息性: 请求已接收。这表示有一个临时响应。

  • 2XX - 成功:这是最重要的响应状态,表示请求已被成功接收、理解和接受。这是我们网络挖掘脚本所寻找的。

  • 3XX - 重定向:这类状态码表示客户端必须采取额外行动。这通常意味着必须进行额外的请求才能到达资源,因此我们的脚本将不得不处理这种情况。我们还需要积极防止循环重定向。在我们的项目中,我们不会处理这种复杂的情况,但在实际应用中,3XX 状态码将需要根据子类别进行专门处理。

维基百科提供了关于各种 3XX 状态码及其每种情况下应采取的操作的良好描述:en.wikipedia.org/wiki/List_of_HTTP_status_codes#3xx_Redirection

  • 4XX - 客户端错误:这意味着我们在发送请求时可能犯了一个错误。可能是 URL 错误,资源无法找到(404),或者我们可能无法访问页面(401403 状态码)。4XX 响应代码有很多,类似于 3XX 代码,我们的程序应该处理各种情况,以确保请求最终成功。

  • 5XX - 服务器错误:恭喜你,你在服务器上找到了或导致了问题!根据实际的状态码,这可能或可能不是可操作的。503(服务不可用)或504(网关超时)是相关的,因为它们表明我们应该稍后再尝试。

学习 HTTP 头信息

HTTP 头信息允许客户端和服务器传递额外的信息。我们不会深入讨论头信息的传输细节,因为 Julia 的HTTP库帮我们避免了处理原始头信息的麻烦。然而,有一些值得提及,因为它们对于网络爬虫很重要:

  • AgeCache-ControlExpires代表页面的有效性,可以用来设置数据刷新时间。

  • Last-ModifiedEtagIf-Modified-Since可用于内容版本控制,以检查页面自上次检索以来是否已更改。

  • CookieSet-Cookie必须使用,以便读取和写入与服务器正确通信所需的 cookie。

  • Content-*系列头信息,例如Content-DispositionContent-LengthContent-TypeContent-Encoding等,在处理和验证响应信息时非常有用。

查看 https://developer.mozilla.org/en-US/docs/Web/HTTP/Headershttps://en.wikipedia.org/wiki/List_of_HTTP_header_fields 以获取关于 HTTP 头信息的完整讨论。

HTTP 消息体

消息体,网络爬虫最重要的部分和原因(网页本身的内容),实际上是响应的一个可选部分。是否存在消息体、其属性及其大小由Content-*系列头信息指定。

理解 HTTP 响应

HTTP.get调用的结果是对象,它紧密地反映了原始 HTTP 响应。该包通过提取原始 HTTP 数据并将其整洁地设置在数据结构中,使我们的生活变得更简单,这使得操作它变得轻而易举。

让我们看看它的属性(或 Julia 语言中的字段):

julia> fieldnames(typeof(resp)) 
(:version, :status, :headers, :body, :request) 

fieldnames函数接受一个类型作为其参数,并返回一个包含字段(或属性)名称的元组。为了获取值的类型,我们可以使用typeof函数,就像前面的例子一样。

对了!到如今,statusheadersbody字段应该听起来很熟悉。version字段表示 HTTP 协议的版本(响应第一行中的HTTP/1.1部分)。今天互联网上的大多数 Web 服务器都使用协议的 1.1 版本,但一个新的主要版本 2.0 几乎准备广泛部署。最后,request字段包含触发当前响应的HTTP.Messages.Request对象的引用。

状态码

让我们更仔细地看看状态码:

julia> resp.status 200 

当然,我们得到了一个有效的响应,这通过200状态码得到了确认。

头信息

关于头信息呢?如前所述,它们包含指示消息体是否存在的重要信息。让我们来看看:

julia> resp.headers 

输出如下:

图片

您的输出在有些值上可能会有所不同,但应该很容易找到我们之前提到的关键 HTTP 头。Content-Length确认了响应体的存在。Content-Type提供了关于如何解释消息体编码的信息(它是一个使用 UTF-8 字符编码的 HTML 文档)。我们可以使用Last-Modified值来优化我们的网络爬虫的缓存和更新频率。

消息体

既然我们已经确认我们有一个响应体,让我们看看它:

julia> resp.body 
193324-element Array{UInt8,1}: 
 0x3c 
 0x21 
 0x44  
# ... output truncated ...  

哎呀,这看起来不像我们预期的网页。不过别担心,这些是原始响应的字节——我们可以轻松地将它们转换为可读的 HTML 字符串。记得我提到过学习字符串时的String方法吗?嗯,这就是它派上用场的地方:

julia> resp_body = String(resp.body) 

您的 REPL 现在应该正在输出一个代表 Julia 维基百科页面的长 HTML 字符串。

如果我们查看前500个字符,我们开始看到熟悉的模式:

julia> resp_body[1:500] 

输出如下:

图片

确实,使用 Chrome 的查看页面源代码将揭示相同的 HTML:

图片

已经确认了——我们刚刚迈出了建立我们的网络爬虫的第一步!

了解关于Pair的知识

当查看响应头时,您可能已经注意到它的类型是一个ArrayPair对象:

julia> resp.headers 
25-element Array{Pair{SubString{String},SubString{String}},1} 

Pair代表一个 Julia 数据结构及其对应的类型。Pair包含一些值,通常用于引用键值关系。两个元素的类型决定了Pair的具体类型。

例如,我们可以用以下方式构造一个Pair

julia> Pair(:foo, "bar") 
:foo => "bar" 

如果我们检查它的类型,我们会看到它是一个SymbolStringPair

julia> typeof(Pair(:foo, "bar")) 
Pair{Symbol,String} 

我们也可以通过使用x => y字面量表示法来创建Pairs

julia> 3 => 'C' 
3 => 'C' 

julia> typeof(3 => 'C') 
Pair{Int64,Char} 

=>双箭头应该很熟悉。这是我们之前在响应头中看到的,例如:

"Content-Type" => "text/html; charset=UTF-8"

显然,一旦创建,就可以访问存储在Pair中的值。一种方法是通过索引它:

julia> p = "one" => 1 
"one" => 1 

julia> p[1] 
"one" 

julia> p[2] 
1 

我们也可以访问firstsecond字段,分别获取firstsecond值:

julia> p.first 
"one" 

julia> p.second 
1 

就像元组一样,Pairs是不可变的,所以这不会起作用:

julia> p.first = "two" 
ERROR: type Pair is immutable 

julia> p[1] = "two" 
ERROR: MethodError: no method matching setindex!(::Pair{String,Int64} 

Pairs是 Julia 的构建块之一,可以用于创建字典,这是最重要的类型之一和数据结构。

字典

字典,称为Dict,是 Julia 最强大和多功能的数据结构之一。它是一个关联集合——它将键与值相关联。您可以将Dict视为查找表实现——给定一个单一的信息,即键,它将返回相应的值。

构建字典

创建一个空的Dict实例就像以下这样:

julia> d = Dict() 
Dict{Any,Any} with 0 entries 

大括号内的信息{Any,Any}表示Dict的键和值的类型。因此,Dict本身的具体类型由其键和值的类型定义。编译器将尽最大努力从其部分类型推断集合的类型。在这种情况下,由于字典为空,无法推断信息,因此 Julia 默认为AnyAny

{Any,Any}类型的Dict允许我们添加任何类型的数据,不加区分。我们可以使用setindex!方法向集合中添加新的键值对:

julia> setindex!(d, "World", "Hello") 
Dict{Any,Any} with 1 entry: 
  "Hello" => "World" 

然而,向Dict中添加值通常使用方括号符号(这与对其索引类似,同时执行赋值操作):

julia> d["Hola"] = "Mundo" 
"Mundo"  

到目前为止,我们只添加了Strings——但正如我所说的,因为我们的Dict接受任何类型的键和值,所以没有约束:

julia> d[:speed] = 6.4 
6.4 

现在是我们的Dict

julia> d 
Dict{Any,Any} with 3 entries: 
  "Hello" => "World" 
  :speed  => 6.4 
  "Hola"  => "Mundo" 

注意,键=>值对不是我们添加它们的顺序。在 Julia 中,Dict不是有序集合。我们将在接下来的几段中更多地讨论这一点。

如果键已存在,相应的值将被更新,返回新值:

julia> d["Hello"] = "Earth" "Earth" 

这是我们的更新后的Dict。注意,现在"Hello"指向"Earth"而不是"World"

julia> d 
Dict{Any,Any} with 3 entries: 
  "Hello" => "Earth" 
  :speed  => 6.4 
  "Hola"  => "Mundo"   

如果在实例化Dict时提供一些初始数据,编译器将能够更好地识别类型:

julia> dt = Dict("age" => 12) 
Dict{String,Int64} with 1 entry: 
  "age" => 12 

我们可以看到,Dict的类型现在限制了键必须是String,值必须是Int——这是我们用来实例化DictPair的类型。现在,如果传递了不同类型的键或值,Julia 将尝试转换它——如果失败,将发生错误:

julia> dt[:price] = 9.99 
MethodError: Cannot `convert` an object of type Symbol to an object of type String 

在某些情况下,自动转换是有效的:

julia> dx = Dict(1 => 11) 
Dict{Int64,Int64} with 1 entry: 
  1 => 11 
julia> dx[2.0] = 12 
12

Julia 已静默地将2.0转换为相应的Int值:

julia> dx 
Dict{Int64,Int64} with 2 entries: 
  2 => 12 
  1 => 11 

但这并不总是有效:

julia> dx[2.4] = 12 
InexactError: Int64(Int64, 2.4) 

我们可以在Dict中存储随机复杂的数据,Julia 会正确推断其类型:

 julia> clients_purchases = Dict( 
       "John Roche" => ["soap", "wine", "apples", "bread"], 
       "Merry Lou"  => ["bottled water", "apples", "cereals", "milk"] 
       ) 
Dict{String,Array{String,1}} with 2 entries: 
  "John Roche" => ["soap", "wine", "apples", "bread"] 
  "Merry Lou"  => ["bottled water", "apples", "cereals", "milk"] 

您也可以在构建时指定和约束Dict的类型,而不是让 Julia 来决定:

julia> dd = Dict{String,Int}("" => 2.0) 
Dict{String,Int64} with 1 entry: 
  "x" => 2 

在这里,我们可以看到类型定义如何覆盖了2.0值(这是一个Float64类型,当然,如前例所示,Julia 已将2.0转换为它的整数等价物)。

我们还可以使用Pairs来创建Dict

julia> p1 = "a" => 1 
"a"=>1 
julia> p2 = Pair("b", 2) 
"b"=>2 
julia> Dict(p1, p2) 
Dict{String,Int64} with 2 entries: 
  "b" => 2 
  "a" => 1 

我们还可以使用Pair的数组:

julia> Dict([p1, p2]) 
Dict{String,Int64} with 2 entries: 
  "b" => 2 
  "a" => 1 

我们可以用元组的数组来做同样的事情:

julia> Dict([("a", 5), ("b", 10)]) 
Dict{String,Int64} with 2 entries: 
  "b" => 10 
  "a" => 5 

最后,可以使用列表推导式来构建Dict

julia> using Dates 
julia> Dict([x => Dates.dayname(x) for x = (1:7)]) 
Dict{Int64,String} with 7 entries: 
  7 => "Sunday" 
  4 => "Thursday" 
  2 => "Tuesday" 
  3 => "Wednesday" 
  5 => "Friday" 
  6 => "Saturday" 
  1 => "Monday" 

您的输出可能会有所不同,因为键可能不会按17的顺序排列。这是一个非常重要的观点——如前所述,在 Julia 中,Dict是无序的。

有序字典

如果您需要您的字典保持有序,可以使用OrderedCollections包(github.com/JuliaCollections/OrderedCollections.jl),特别是OrderedDict

pkg> add OrderedCollections 
julia> using OrderedCollections, Dates 
julia> OrderedDict(x => Dates.monthname(x) for x = (1:12)) 
DataStructures.OrderedDict{Any,Any} with 12 entries: 
  1  => "January" 
  2  => "February" 
  3  => "March" 
  4  => "April" 
  5  => "May" 
  6  => "June" 
  7  => "July" 
  8  => "August" 
  9  => "September" 
  10 => "October" 
  11 => "November" 
  12 => "December" 

现在元素是按照它们添加到集合中的顺序存储的(从 112)。

与字典一起工作

正如我们已经看到的,我们可以使用方括号符号索引 Dict

julia> d = Dict(:foo => 1, :bar => 2) 
Dict{Symbol,Int64} with 2 entries: 
  :bar => 2 
  :foo => 1 

julia> d[:bar] 
2 

尝试访问一个未定义的键将导致 KeyError,如下所示:

julia> d[:baz] 
ERROR: KeyError: key :baz not found 

为了避免这种情况,我们可以检查键是否首先存在:

julia> haskey(d, :baz) 
false 

作为一种替代方法,如果我们想在键不存在时也获取默认值,我们可以使用以下方法:

julia> get(d, :baz, 0) 
0 

get 函数有一个更强大的双胞胎,get!,它也会将搜索到的键存储到 Dict 中,使用默认值:

julia> d 
Dict{Symbol,Int64} with 2 entries: 
  :bar => 2 
  :foo => 1 

julia> get!(d, :baz, 100) 
100 

julia> d 
Dict{Symbol,Int64} with 3 entries: 
  :baz => 100 
  :bar => 2 
  :foo => 1 

julia> haskey(d, :baz) 
true  

如果你在想,函数名末尾的感叹号是有效的——它表示一个重要的 Julia 命名约定。这应该被视为一个警告,即使用该函数将修改其参数的数据。在这种情况下,get! 函数将添加 :baz = 100PairdDict 中。

删除键值 Pair 只需调用 delete!(注意这里也有感叹号的存在):

julia> delete!(d, :baz) 
Dict{Symbol,Int64} with 2 entries: 
  :bar => 2 
  :foo => 1 

julia> haskey(d, :baz) 
false 

如请求所示,:baz 键及其对应值已经消失。

我们可以使用名为 keysvalues 的函数请求键和值的集合。它们将返回它们底层集合的迭代器:

julia> keys(d) 
Base.KeySet for a Dict{Symbol,Int64} with 2 entries. Keys: 
  :bar 
  :foo 

julia> values(d) 
Base.ValueIterator for a Dict{Symbol,Int64} with 2 entries. Values: 
  2 
  1 

使用 collect 获取相应的数组:

julia> collect(keys(d)) 
2-element Array{Symbol,1}: 
 :bar 
 :foo 

julia> collect(values(d)) 
2-element Array{Int64,1}: 
 2 
 1 

我们可以将一个 Dict 与另一个 Dict 结合:

julia> d2 = Dict(:baz => 3) 
Dict{Symbol,Int64} with 1 entry: 
  :baz => 3 

julia> d3 = merge(d, d2) 
Dict{Symbol,Int64} with 3 entries: 
  :baz => 3 
  :bar => 2 
  :foo => 1 

如果一些键在多个字典中存在,则将保留最后一个集合中的值:

julia> merge(d3, Dict(:baz => 10)) 
Dict{Symbol,Int64} with 3 entries: 
  :baz => 10 
  :bar => 2 
  :foo => 1 

使用 HTTP 响应

在了解了 Julia 的字典数据结构之后,我们现在可以更仔细地查看 respheaders 属性,我们的 HTTP 响应对象。

为了更容易访问各种标题,首先让我们将 Pair 数组转换为 Dict

julia> headers = Dict(resp.headers) 
Dict{SubString{String},SubString{String}} with 23 entries: 
"Connection"     => "keep-alive" 
  "Via"          => "1.1 varnish (Varnish/5.1), 1.1 varnish (Varni... 
  "X-Analytics"  => "ns=0;page_id=38455554;https=1;nocookies=1" 
#... output truncated... #

我们可以检查 Content-Length 值以确定是否有响应体。如果它大于 0,这意味着我们收到了一个 HTML 消息:

julia> headers["Content-Length"] 
"193324" 

重要的是要记住,headers 字典中的所有值都是字符串,因此我们不能直接比较它们:

julia> headers["Content-Length"] > 0 
ERROR: MethodError: no method matching isless(::Int64, ::String) 

我们需要首先将其解析为整数:

julia> parse(Int, headers["Content-Length"]) > 0 
true 

操作响应体

之前,我们将响应体读入一个 String 并存储在 resp_body 变量中。它是一个长的 HTML 字符串,从理论上讲,我们可以使用 Regex 和其他字符串处理函数来查找和提取我们所需的数据。然而,这种方法将非常复杂且容易出错。在 HTML 文档中搜索内容最好的方法是使用 HTML 和 CSS 选择器。唯一的问题是这些选择器不作用于字符串——它们只对 文档对象模型DOM)起作用。

构建页面 DOM 表示

DOM 代表 HTML 文档的内存结构。它是一种数据结构,允许我们以编程方式操作底层 HTML 元素。DOM 将文档表示为一个逻辑树,我们可以使用选择器来遍历和查询这个层次结构。

使用 Gumbo 解析 HTML

Julia 的Pkg生态系统提供了对Gumbo的访问,这是一个 HTML 解析库。提供 HTML 字符串后,Gumbo会将其解析成文档及其对应的 DOM。这个包是使用 Julia 进行网络爬取的重要工具,所以让我们添加它。

如往常一样,使用以下命令安装:

pkg> add Gumbo 
julia> using Gumbo  

现在,我们已经准备好将 HTML 字符串解析成 DOM,如下所示:

julia> dom = parsehtml(resp_body)
 HTML Document

dom变量现在引用了一个Gumbo.HTMLDocument,这是网页的内存中 Julia 表示。它是一个只有两个字段的简单对象:

julia> fieldnames(typeof(dom)) 
(:doctype, :root)  

doctype代表 HTML 的<!DOCTYPE html>元素,这是维基百科页面使用的:

julia> dom.doctype 
"html" 

现在,让我们关注root属性。这实际上是 HTML 页面的最外层元素——包含其余元素的<html>标签。它为我们提供了进入 DOM 的入口点。我们可以询问Gumbo它的属性:

julia> dom.root.attributes 
Dict{AbstractString,AbstractString} with 3 entries: 
  "class" => "client-nojs" 
  "lang"  => "en" 
  "dir"   => "ltr" 

它是一个Dict,键代表 HTML 属性,值是属性的值。确实,它们与页面的 HTML 相匹配:

图片

还有一个类似的attrs方法,它具有相同的作用:

julia> attrs(dom.root) 
Dict{AbstractString,AbstractString} with 3 entries: 
  "class" => "client-nojs" 
  "lang"  => "en" 
  "dir"   => "ltr" 

当不确定时,我们可以使用tag方法来询问元素的名称:

julia> tag(dom.root) 
:HTML 

Gumbo提供了一个children方法,它返回一个包含所有嵌套HTMLElement的数组。如果你直接执行julia> children(dom.root),REPL 的输出将难以跟踪。HTMLElement的 REPL 表示是其 HTML 代码,对于具有许多子元素的最高层元素,它将填满许多终端屏幕。让我们使用for循环遍历子元素并仅显示它们的标签:

julia> for c in children(dom.root) 
           @show tag(c) 
       end 
tag(c) = :head 
tag(c) = :body 

好多了!

由于子元素是集合的一部分,我们可以对它们进行索引:

julia> body = children(dom.root)[2]; 

请注意分号(;)的用法。当在 REPL(Read-Eval-Print Loop,即交互式解释器)的语句末尾使用时,它会抑制输出(因此我们不会看到其他情况下会输出的非常长的<body> HTML 代码)。现在body变量将引用一个HTMLElement{:body}的实例:

HTMLElement{:body}: 
<body class="mediawiki ltr sitedir-ltr mw-hide-empty-elt ns-0 ns-subject page-Julia_programming_language rootpage-Julia_programming_language skin-vector action-view"> 
# ... output truncated ...

我们需要的最后一个方法是getattr,它返回属性名称的值。如果元素没有定义该属性,它将引发一个KeyError

julia> getattr(dom.root, "class") 
"client-nojs" 

julia> getattr(dom.root, "href") # oops! 
ERROR: KeyError: key "href" not found 

询问<html>标签的href属性没有意义。果然,我们很快得到了一个KeyError,因为href不是这个HTMLElement的属性。

编码防御性

像之前的错误一样,当它是更大脚本的一部分时,有可能完全改变程序的执行,导致不希望的结果,甚至可能造成损失。一般来说,当程序执行过程中发生意外时,它可能会使软件处于错误状态,使得无法返回正确的值。在这种情况下,而不是继续执行并可能在整个执行堆栈中传播问题,最好通过抛出 Exception 明确通知调用代码关于这种情况。

许多函数,无论是 Julia 的核心函数还是第三方包中的函数,都很好地使用了错误抛出机制。检查你使用的函数的文档并查看它们抛出什么类型的错误是一个好习惯。在编程术语中,错误被称为异常。

就像 getattr 的情况一样,Gumbo 包的作者警告我们,尝试读取未定义的属性将导致 KeyError 异常。我们将很快学习如何通过在代码中捕获异常、获取有关问题的信息以及停止或允许异常进一步向上传播调用堆栈来处理异常。有时这是最好的方法,但我们不希望过度使用这种方法,因为以这种方式处理错误可能会消耗大量资源。处理异常比执行简单的数据完整性检查和分支要慢得多。

对于我们的项目,第一道防线是简单地检查属性是否确实定义在元素中。我们可以通过检索属性 Dict 的键并检查我们想要的键是否是集合的一部分来实现这一点。这是一个单行代码:

julia> in("href", collect(keys(attrs(dom.root)))) 
false 

显然,href 不是 <html> 标签的属性。

使用这种方法,我们可以在尝试查找属性值之前轻松地编写逻辑来检查属性的存在。

管道操作符

阅读多层嵌套函数可能会对大脑造成负担。上一个例子 collect(keys(attrs(dom.root))) 可以使用 Julia 的管道操作符 |> 重新编写以提高可读性。

例如,以下代码片段嵌套了三个函数调用,每个内部函数都成为最外层函数的参数:

julia> collect(keys(attrs(dom.root))) 
3-element Array{AbstractString,1}: 
 "class" 
 "lang" 
 "dir"

这可以通过使用管道操作符将函数链式调用重写以提高可读性。这段代码会产生完全相同的结果:

julia> dom.root |> attrs |> keys |> collect 
3-element Array{AbstractString,1}: 
 "class" 
 "lang" 
 "dir" 

|> 操作符的作用是取第一个值的输出,并将其作为下一个函数的参数。所以 dom.root |> attrs 等同于 attrs(dom.root)。不幸的是,管道操作符仅适用于单参数函数。但它在清理代码、大幅提高可读性方面仍然非常有用。

对于更高级的管道功能,你可以查看 Lazy 包,特别是 @>@>>,请参阅 github.com/MikeInnes/Lazy.jl#macros

像专业人士一样处理错误

有时候,编写防御性代码可能不是解决方案。也许你的程序的关键部分需要从网络上读取文件或访问数据库。如果由于临时网络故障无法访问资源,在没有数据的情况下,你实际上真的无能为力。

try...catch 语句

如果你确定你的代码中某些部分可能会因为超出你控制的条件(即异常条件——因此得名异常)而执行偏离轨道,你可以使用 Julia 的try...catch语句。这正是它的名字——你指示编译器尝试一段代码,如果由于问题而抛出异常,就捕获它。异常被捕获的事实意味着它不会在整个应用程序中传播。

让我们看看它是如何工作的:

julia> try 
    getattr(dom.root, "href") 
catch 
    println("The $(tag(dom.root)) tag doesn't have a 'href' attribute.") 
end 
The HTML tag doesn't have a 'href' attribute. 

在这个例子中,一旦遇到错误,try分支中的代码执行就会在 exactly 那个点停止,并且立即在catch分支中继续执行。

如果我们按如下方式修改代码片段,就会更清晰:

julia> try 
    getattr(dom.root, "href") 
    println("I'm here too") 
catch 
    println("The $(tag(dom.root)) tag doesn't have a 'href' attribute.") 
end 
The HTML tag doesn't have a 'href' attribute. 

新添加的行println("I'm here too")没有执行,正如消息没有输出的事实所证明的那样。

当然,如果没有抛出异常,事情就会变得清晰:

julia> try 
getattr(dom.root, "class") 
    println("I'm here too") 
catch 
    println("The $(tag(dom.root)) tag doesn't have a 'href' attribute.") 
end 
I'm here too 

catch构造函数接受一个可选参数,即由try块抛出的Exception对象。这允许我们检查异常并根据其属性分支我们的代码。

在我们的例子中,KeyError异常是 Julia 内置的。当我们尝试访问或删除一个不存在的元素(例如Dict中的键或HTMLElement的属性)时,会抛出KeyError异常。所有KeyError实例都有一个键属性,它提供了有关缺失数据的信息。因此,我们可以使我们的代码更加通用:

julia> try 
     getattr(dom.root, "href") 
catch ex 
    if isa(ex, KeyError)  
            println("The $(tag(dom.root)) tag doesn't have a '$(ex.key)' attribute.") 
    else  
           println("Some other exception has occurred") 
    end 
end 
The HTML tag doesn't have a 'href' attribute. 

在这里,我们将异常作为ex变量传递到catch块中。然后我们检查是否处理的是KeyError异常——如果是,我们使用这个信息通过访问ex.key字段来检索缺失的键来显示自定义错误。如果它是一种不同类型的异常,我们显示一个通用的错误消息:

julia> try 
     error("Oh my!") 
catch ex 
    if isa(ex, KeyError)  
            println("The $(tag(dom.root)) tag doesn't have a '$(ex.key)' attribute.") 
    else  
           println("Some exception has occurred") 
    end 
end 
Some exception has occurred 

finally 子句

在执行状态改变或使用文件或数据库等资源的代码中,通常需要在代码完成后进行一些清理工作(例如关闭文件或数据库连接)。这段代码通常会进入try分支——但是,如果抛出了异常会发生什么呢?

在这种情况下,finally子句就派上用场了。这可以在try之后或catch分支之后添加。finally块中的代码将被保证执行,无论是否抛出异常:

julia> try 
    getattr(dom.root, "href") 
catch ex 
    println("The $(tag(dom.root)) tag doesn't have a '$(ex.key)' attribute.") 
finally 
    println("I always get called") 
end 
The HTML tag doesn't have a 'href' attribute. 
I always get called 

没有catchfinallytry是非法的:

julia> try getattr(dom.root, "href") end syntax: try without catch or finally 

我们需要提供一个catchfinally块(或两者都提供)。

try/catch/finally块将返回最后评估的表达式,因此我们可以将其捕获到变量中:

julia> result = try 
           error("Oh no!") 
       catch ex 
           "Everything is under control" 
        end 
"Everything is under control" 

julia> result 
"Everything is under control" 

在错误上抛出异常

作为开发者,当我们的代码遇到问题且不应继续执行时,我们也有创建和抛出异常的选项。Julia 提供了一系列内置异常,涵盖了多种用例。您可以在docs.julialang.org/en/stable/manual/control-flow/#Built-in-Exceptions-1上了解它们。

为了抛出异常,我们使用名为throw的函数。例如,如果我们想复制 Gumbo 的getattr方法引发的错误,我们只需调用以下操作:

julia> throw(KeyError("href")) 
ERROR: KeyError: key "href" not found 

如果 Julia 提供的内置异常对于您的情况来说不够相关,该语言提供了一个通用的错误类型,即ErrorException。它接受一个额外的msg参数,该参数应提供更多关于错误本质的详细信息:

julia> ex = ErrorException("To err is human, but to really foul things up you need a computer.") 
ErrorException("To err is human, but to really foul things up you need a computer.") 

julia> throw(ex) 
ERROR: To err is human, but to really foul things up you need a computer. 

julia> ex.msg 
"To err is human, but to really foul things up you need a computer." 

Julia 提供了抛出ErrorException的快捷方式,即error函数:

julia> error("To err is human - to blame it on a computer is even more so.") 
ERROR: To err is human - to blame it on a computer is even more so. 

重新抛出异常

但如果我们意识到我们捕获的异常无法(或不应)由我们的代码处理怎么办?例如,假设我们预计会捕获一个可能缺失的属性,但结果我们得到了一个Gumbo解析异常。这种问题必须在上层的执行堆栈中处理,可能尝试再次获取网页并重新解析,或者为管理员记录一个错误信息。

如果我们自行throw异常,初始错误的来源(堆栈跟踪)将会丢失。对于这种情况,Julia 提供了rethrow函数,可以使用如下方式:

julia> try 
           Dict()[:foo] 
       catch ex 
           "nothing to see here" 
       end 
"nothing to see here" 

如果我们简单地自行抛出异常,这就是会发生的情况:

julia> try 
           Dict()[:foo] 
       catch ex 
           throw(ex) 
       end 
ERROR: KeyError: key :foo not found 
Stacktrace: 
 [1] top-level scope at REPL 

我们抛出KeyError异常,但异常的来源丢失;它看起来像是在我们的代码的catch块中产生的。与以下示例进行对比,其中我们使用了rethrow

julia> try 
           Dict()[:foo] 
       catch ex 
            rethrow(ex) 
       end 
ERROR: KeyError: key :foo not found 
Stacktrace: 
 [1] getindex(::Dict{Any,Any}, ::Symbol) at ./dict.jl:474 
 [2] top-level scope at REPL[140]

原始异常正在被重新抛出,而不改变堆栈跟踪。现在我们可以看到异常起源于dict.jl文件。

学习函数

在我们编写第一个完整的 Julia 程序(网络爬虫)之前,我们还需要进行另一个重要的转折。这是最后一个,我保证。

随着我们的代码变得越来越复杂,我们应该开始使用函数。REPL 由于其快速输入输出反馈循环,非常适合探索性编程,但对于任何非*凡的软件,使用函数是最佳选择。函数是 Julia 的核心部分,它促进了可读性、代码重用和性能。

在 Julia 中,一个函数是一个对象,它接受一个值元组作为参数并返回一个值:

julia> function add(x, y) 
           x + y 
       end 
add (generic function with 1 method) 

对于函数声明,还有一个紧凑的赋值形式

julia> add(x, y) = x + y 
add (generic function with 1 method) 

这种第二种形式非常适合简单的单行函数。

调用一个函数只是简单地调用它的名字并传递所需的参数:

julia> add(1, 2) 
3 

返回关键字

如果你有过编程经验,你可能会惊讶地看到,尽管我们没有在函数体中放置任何显式的return语句,调用add函数仍然可以正确地返回预期的值。在 Julia 中,函数会自动返回最后一个评估的表达式的结果。这通常是函数体中的最后一个表达式。

明确的return关键字也是可用的。使用它将导致函数立即退出,并将传递给return语句的值返回:

julia> function add(x, y) 
           return "I don't feel like doing math today" 
           x + y 
       end 
add (generic function with 1 method) 

julia> add(1, 2) 
"I don't feel like doing math today" 

返回多个值

虽然 Julia 不支持返回多个值,但它确实提供了一个非常接*实际操作的巧妙技巧。任何函数都可以返回一个元组。由于元组的构造和析构非常灵活,这种方法非常强大且易于阅读:

julia> function addremove(x, y) 
           x+y, x-y 
       end 
addremove (generic function with 1 method) 

julia> a, b = addremove(10, 5) 
(15, 5) 

julia> a 
15 

julia> b 
5 

在这里,我们定义了一个名为addremove的函数,它返回一个包含两个整数的元组。我们可以通过简单地给每个元素分配一个变量来提取元组内的值。

可选参数

函数参数可以有合理的默认值。在这种情况下,Julia 允许定义默认值。当它们被提供时,相应的参数在每次调用时不再需要显式传递:

julia> function addremove(x=100, y=10) 
           x+y, x-y 
       end 
addremove (generic function with 3 methods) 

这个函数为xy都设置了默认值。我们可以不传递任何参数来调用它:

julia> addremove() 
(110, 90) 

这个片段演示了当在函数调用时没有提供默认值时,Julia 如何使用默认值。

我们只能传递第一个参数——对于第二个参数,将使用默认值:

julia> addremove(5) 
(15, -5) 

最后,我们可以传递两个参数;所有默认值都将被覆盖:

julia> addremove(5, 1) 
(6, 4) 

关键字参数

需要长列表参数的函数可能难以使用,因为程序员必须记住期望值的顺序和类型。对于这种情况,我们可以定义接受标记参数的函数。这些被称为关键字参数

为了定义接受关键字参数的函数,我们需要在函数未标记参数列表之后添加一个分号,并跟随着一个或多个keyword=value对。实际上,我们在第二章,创建我们的第一个 Julia 应用程序时遇到了这样的函数,当时我们使用Gadfly绘制了鸢尾花数据集:

plot(iris, x=:SepalLength, y=:PetalLength, color=:Species) 

在这个例子中,xycolor都是关键字参数。

关键字参数函数的定义如下:

function thermal_confort(temperature, humidity; scale = :celsius, age = 35) 

在这里,我们定义了一个新的函数thermal_confort,它有两个必需的参数temperaturehumidity。该函数还接受两个关键字参数scaleage,分别具有默认值:celsius35。对于所有关键字参数来说,具有默认值是必要的。

调用此类函数意味着同时使用位置参数和关键字参数:

thermal_confort(27, 56, age = 72, scale = :fahrenheit)

如果没有提供关键字参数的值,将使用默认值。

关键字参数默认值是从左到右评估的,这意味着默认表达式可以引用先前的关键字参数:

function thermal_confort(temperature, humidity; scale = :celsius, age = 35, health_risk = age/100) 

注意,我们在health_risk的默认值中引用了关键字参数age

记录函数

Julia 自带强大的代码文档功能。使用方法简单——任何出现在对象之前顶级字符串都将被解释为文档(它被称为docstring)。docstring 被解释为 Markdown,因此我们可以使用标记来丰富格式。

thermal_confort函数的文档可能如下所示:

""" 
        thermal_confort(temperature, humidity; <keyword arguments>) 
Compute the thermal comfort index based on temperature and humidity. It can optionally take into account the age of the patient. Works for both Celsius and Fahrenheit.  
# Examples: 
```julia-repl

julia> thermal_confort(32, 78)

12

```py 
# Arguments 
- temperature: the current air temperature 
- humidity: the current air humidity 
- scale: whether :celsius or :fahrenheit, defaults to :celsius 
- age: the age of the patient 
""" 
function thermal_confort(temperature, humidity; scale = :celsius, age = 35)

现在,我们可以通过使用 REPL 的帮助模式来访问我们函数的文档:

help?> thermal_confort 

输出如下所示:

非常有用,不是吗?文档字符串也可以用来为你的 Julia 项目生成完整的文档,这需要外部包的帮助,这些包构建完整的 API 文档作为独立的网站、Markdown 文档、PDF 文档等。我们将在第十一章中看到如何做到这一点,创建 Julia 包

编写基本的网络爬虫 – 开始

现在我们已经准备好编写我们的第一个完整的 Julia 程序——一个简单的网络爬虫。这个迭代将向 Julia 的维基百科页面发起请求,解析它并提取所有内部 URL,将它们存储在Array中。

设置我们的项目

我们需要做的第一件事是设置一个专用项目。这是通过使用Pkg来完成的。这是一个非常重要的步骤,因为它允许我们有效地管理和版本化程序所依赖的包。

首先,我们需要为我们的软件创建一个文件夹。创建一个——让我们称它为WebCrawler。我会使用 Julia 来做这件事,但你可以按照你喜欢的任何方式来做:

julia> mkdir("WebCrawler") 
"WebCrawler" 

julia> cd("WebCrawler/") 

现在我们可以使用Pkg来添加依赖项。当我们开始一个新的项目时,我们需要初始化它。这是通过以下方式实现的:

pkg> activate .

这告诉Pkg我们想要在当前项目中管理依赖项,而不是全局操作。你会注意到光标已经改变,指示了活动项目的名称,WebCrawler

(WebCrawler) pkg> 

到目前为止,我们安装的所有其他包都在全局环境中,这可以通过(v1.0)光标来指示:

(v1.0) pkg> 

(v1.0)是全局环境,标记了当前安装的 Julia 版本。如果你在不同的 Julia 版本上尝试这些示例,你会得到不同的标签。

如果我们检查状态,我们会看到在项目的环境中还没有安装任何包:

(WebCrawler) pkg> st 
    Status `Project.toml` 

我们软件将有两个依赖项——HTTPGumbo。是时候添加它们了:

(WebCrawler) pkg> add HTTP 
(WebCrawler) pkg> add Gumbo 

现在我们可以创建一个新的文件来存放我们的代码。让我们称它为webcrawler.jl。它可以由 Julia 创建:

julia> touch("webcrawler.jl") 
"webcrawler.jl" 

编写 Julia 程序

与我们在 REPL 和 IJulia 笔记本中的先前工作不同,这将是一个独立的程序:所有逻辑都将放在这个 webcrawler.jl 文件中,准备好后,我们将使用 julia 二进制文件来执行它。

Julia 文件是从上到下解析的,所以我们需要按正确的顺序提供所有必要的指令(使用语句、变量初始化、函数定义等)。我们将基本上将本章中迄今为止所采取的所有步骤压缩到这个小程序中。

为了使事情更简单,最好使用一个完整的 Julia 编辑器。在 Atom/Juno 或 Visual Studio Code(或你喜欢的任何编辑器)中打开 webcrawler.jl

我们想要做的第一件事是通知 Julia 我们计划使用 HTTPGumbo 包。我们可以写一个单独的 using 语句并列出多个依赖项,用逗号分隔:

using HTTP, Gumbo 

此外,我们决定我们想要使用 Julia 的维基百科页面来测试我们的爬虫。链接是 en.wikipedia.org/wiki/Julia_(programming_language)。将此类配置值存储在常量中而不是在整个代码库中散布 魔法字符串 是一种好的做法:

const PAGE_URL = "https://en.wikipedia.org/wiki/Julia_(programming_language)" 

我们还说过我们想要将所有链接存储在一个 Array 中——让我们也设置一下。记住,Julia 中的常量主要与类型相关,所以在我们声明后向数组中推入值是没有问题的:

const LINKS = String[] 

在这里,我们将 LINKS 常量初始化为一个空的 String 数组。记法 String[]Array{String,1}()Vector{String}() 产生相同的结果。它基本上表示空的 Array 字面量 [] 加上 Type 约束 String——创建一个 String 值的 Vector

接下来的步骤是——获取页面,寻找成功的响应(状态 200),然后检查头信息以查看是否收到了消息体(Content-Length 大于零)。在这个第一次迭代中,我们只需要做一次。但向前看,对于游戏的最终版本,我们可能需要在每个游戏会话中重复这个过程多达六次(因为会有多达六度维基百科,所以我们需要爬取多达六个页面)。我们能做的最好的事情是编写一个通用函数,它只接受页面 URL 作为其唯一参数,获取页面,执行必要的检查,并在可用的情况下返回消息体。让我们把这个函数叫做 fetchpage

function fetchpage(url)
    response = HTTP.get(url)
    if response.status == 200 && parse(Int, Dict(response.headers)["Content-Length"]) > 0
        String(response.body)
    else
        ""
    end
end   

首先,我们调用 HTTP.get(url),将 HTTP.Messages.Response 对象存储在 response 变量中。然后我们检查响应状态是否为 200,以及 Content-Length 头是否大于 0。如果是,我们将消息体读取到字符串中。如果不是,我们返回一个空字符串 "" 来表示空体。这里有很多 if 条件——看起来是时候我们仔细看看条件 if/else 语句了,因为它们真的很重要。

if、elseif 和 else 语句的条件评估

所有程序,除了最基础的,都必须能够评估变量并根据它们的当前值执行不同的逻辑分支。条件评估允许根据布尔表达式的值执行(或不执行)代码的一部分。Julia 提供了 ifelseifelse 语句来编写条件表达式。它们的工作方式如下:

julia> x = 5 
5 

julia> if x < 0 
           println("x is a negative number") 
      elseif x > 0 
           println("x is a positive number greater than 0") 
      else  
           println("x is 0") 
      end 
x is a positive number greater than 0 

如果条件 x < 0 为真,则其基础块将被评估。如果不为真,则表达式 x > 0 作为 elseif 分支的一部分被评估。如果为真,则评估其对应的块。如果两个表达式都不为真,则评估 else 块。

elseifelse 块是可选的,我们可以使用任意数量的 elseif 块。在 ifelseifelse 构造中的条件会被评估,直到第一个返回 true。然后评估相关的块,并返回其最后计算出的值,退出条件评估。因此,Julia 中的条件语句也会返回一个值——所选择分支中最后执行语句的值。以下代码展示了这一点:

julia> status = if x < 0 
                         "x is a negative number" 
                  elseif x > 0 
                         "x is a positive number greater than 0" 
                   else  
                         "x is 0" 
                   end 
"x is a positive number greater than 0" 

julia> status 
"x is a positive number greater than 0" 

最后,非常重要的一点是要记住,if 块不会引入局部作用域。也就是说,在其中定义的变量在块退出后仍然可访问(当然,前提是相应的分支已被评估):

julia> status = if x < 0 
            "x is a negative number" 
       elseif x > 0 
            y = 20 
            "x is a positive number greater than 0" 
       else  
            "x is 0" 
       end 
"x is a positive number greater than 0" 

julia> y 
20 

我们可以看到,在 elseif 块中初始化的 y 变量在条件表达式外部仍然可访问。

如果我们声明变量为 local,则可以避免这种情况:

julia> status = if x < 0 
            "x is a negative number" 
       elseif x > 0 
            local z = 20 
            "x is a positive number greater than 0" 
       else  
            "x is 0" 
       end 
"x is a positive number greater than 0" 

julia> z 
UndefVarError: z not defined

当声明为 local 时,变量不再会从 if 块中 泄漏

三元运算符

可以使用三元运算符 ? : 表达 ifthenelse 类型的条件。其语法如下:

x ? y : z 

如果 x 为真,则评估表达式 y;否则,评估 z。例如,考虑以下代码:

julia> x = 10 
10 

julia> x < 0 ? "negative" : "positive" 
"positive" 

短路评估

Julia 提供了一种更简洁的评估类型——短路评估。在一系列由 &&|| 操作符连接的布尔表达式中,只评估最小数量的表达式——只要足以确定整个链的最终布尔值。我们可以利用这一点来返回某些值,具体取决于什么被评估。例如:

julia> x = 10 
10 

julia> x > 5 && "bigger than 5" "bigger than 5"

在表达式 A && B 中,只有当 A 评估为 true 时,第二个表达式 B 才会进行评估。在这种情况下,整个表达式的返回值是子表达式 B 的返回值,在先前的例子中是 大于 5

相反,如果 A 评估为 false,则 B 完全不会进行评估。因此,请注意——整个表达式将返回一个 false 布尔值(而不是字符串!):

julia> x > 15 && "bigger than 15" 
false 

同样的逻辑适用于逻辑 or 操作符,||

julia> x < 5 || "greater than 5"
"greater than 5"

在表达式 A || B 中,只有当 A 评估为 false 时,第二个表达式 B 才会被评估。当第一个子表达式评估为 true 时,同样的逻辑也适用;true 将是整个表达式的返回值:

julia> x > 5 || "less than 5" 
true 

注意运算符优先级

有时短路表达式可能会让编译器困惑,导致错误或意外结果。例如,短路表达式经常与赋值操作一起使用,如下所示:

julia> x > 15 || message = "That's a lot" 

这将因为 syntax: invalid assignment location "(x > 15) || message" 错误而失败,因为 = 赋值运算符的优先级高于逻辑 or||。可以通过使用括号来显式控制评估顺序来轻松修复:

julia> x > 15 || (message = "That's a lot") 
"That's a lot" 

这是一件需要记住的事情,因为它是初学者常见的错误来源。

继续爬虫的实现

到目前为止,你的代码应该看起来像这样:

using HTTP, Gumbo 

const PAGE_URL = "https://en.wikipedia.org/wiki/Julia_(programming_language)" 
const LINKS = String[] 

function fetchpage(url) 
  response = HTTP.get(url) 
  if response.status == 200 && parse(Int, Dict(response.headers)["Content-Length"]) > 0 
    String(response.body) 
  else 
    "" 
  end 
end 

现在应该很清楚,if/else 语句返回的是响应体或空字符串。由于这是 fetchpage 函数内部最后评估的代码片段,这个值也成为了整个函数的返回值。

所有都很好,我们现在可以使用 fetchpage 函数获取维基百科页面的 HTML 内容并将其存储在 content 变量中:

content = fetchpage(PAGE_URL)  

如果获取操作成功且 content 不是空字符串,我们可以将 HTML 字符串传递给 Gumbo 以构建 DOM。然后,我们可以遍历此 DOM 的 root 元素的子元素并查找链接(使用 a 标签选择器)。对于每个元素,我们想要检查 href 属性,并且只有当它指向另一个维基百科页面时才存储其值:

if ! isempty(content) 
  dom = Gumbo.parsehtml(content) 
  extractlinks(dom.root) 
end

提取链接的函数是:

function extractlinks(elem) 
  if  isa(elem, HTMLElement) &&  
      tag(elem) == :a && in("href", collect(keys(attrs(elem)))) 
        url = getattr(elem, "href") 
        startswith(url, "/wiki/") && push!(LINKS, url) 
  end 

  for child in children(elem) 
    extractlinks(child) 
  end 
end 

在这里,我们声明一个 extractlinks 函数,它接受一个名为 elemGumbo 元素作为其唯一参数。然后我们检查 elem 是否是一个 HTMLElement,如果是,我们检查它是否对应于一个链接标签(表示 <a> HTML 标签的 Julia Symbol :a)。然后我们检查该元素是否定义了 href 属性,以避免出现 KeyError。如果一切正常,我们获取 href 元素的值。最后,如果 href 的值是一个内部 URL——即以 /wiki/ 开头的 URL——我们将它添加到我们的 LINKS 数组 中。

一旦我们检查完元素中的链接,我们检查它是否包含其他嵌套的 HTML 元素。如果包含,我们想要对其每个子元素重复相同的流程。这就是最终 for 循环所做的。

剩下的唯一任务是显示我们文件末尾填充好的 LINKS 数组。由于一些链接可能会在页面中出现多次,让我们确保通过使用 unique 函数将 数组 精简为仅包含唯一元素:

display(unique(LINKS))  

现在,我们可以通过在存储文件的文件夹中打开终端来执行此脚本。然后运行——$ julia webcrawler.jl

链接很多,所以输出将会相当长。以下是列表的顶部:

 $ julia webcrawler.jl 
440-element Array{String,1}: 
 "/wiki/Programming_paradigm" 
 "/wiki/Multi-paradigm_programming_language" 
 "/wiki/Multiple_dispatch" 
 "/wiki/Object-oriented_programming" 
 "/wiki/Procedural_programming" 
# ... output truncated ... 

通过查看输出,我们会注意到在第一次优化中,一些链接指向特殊的维基百科页面——包含如 File:, /Category:, /Help:, /Special: 等部分的页面。因此,我们可以直接跳过所有包含列,即 :, 的 URL,因为这些不是文章,对我们游戏没有用。

要做到这一点,请查找以下行:

startswith(url, "/wiki/") && push!(LINKS, url)

将前面的行替换为以下内容:

startswith(url, "/wiki/") && ! occursin(":", url) && push!(LINKS, url) 

如果你现在运行程序,你应该会看到来自 Julia 维基百科页面的所有链接到其他维基百科文章的 URL 列表。

这是完整的代码:

using HTTP, Gumbo 

const PAGE_URL = "https://en.wikipedia.org/wiki/Julia_(programming_language)" 
const LINKS = String[] 

function fetchpage(url) 
  response = HTTP.get(url) 
  if response.status == 200 && parse(Int, Dict(response.headers)["Content-Length"]) > 0 
    String(response.body) 
  else 
    "" 
  end 
end 

function extractlinks(elem) 
  if  isa(elem, HTMLElement) && tag(elem) == :a && in("href", collect(keys(attrs(elem)))) 
        url = getattr(elem, "href") 
        startswith(url, "/wiki/") && ! occursin(":", url) && push!(LINKS, url) 
  end 

  for child in children(elem) 
    extractlinks(child) 
  end 
end 

content = fetchpage(PAGE_URL) 

if ! isempty(content) 
  dom = Gumbo.parsehtml(content)
  extractlinks(dom.root) 
end

display(unique(LINKS)) 

摘要

网络爬虫是数据挖掘的关键组成部分,Julia 提供了一个强大的工具箱来处理这些任务。在本章中,我们讨论了构建网络爬虫的基本原理。我们学习了如何使用 Julia 网络客户端请求网页以及如何读取响应,如何使用 Julia 强大的 Dict 数据结构读取 HTTP 信息,如何通过处理错误来使我们的软件更具鲁棒性,如何通过编写函数并对其进行文档化来更好地组织我们的代码,以及如何使用条件逻辑来做出决策。

带着这些知识,我们构建了我们网络爬虫的第一个版本。在下一章中,我们将对其进行改进,并使用它来提取即将推出的 Wiki 游戏的数据。在这个过程中,我们将更深入地了解语言,学习类型、方法和模块,以及如何与关系型数据库交互。

第四章:构建 Wiki 游戏网络爬虫

哇,第三章,设置 Wiki 游戏,真是一次刺激的旅程!为我们维基百科游戏打下基础带我们经历了一场真正的学习之旅。在快速回顾了网络和网页的工作原理之后,我们深入研究了语言的关键部分,研究了字典数据结构和相应的数据类型、条件表达式、函数、异常处理,甚至非常实用的管道操作符(|>)。在这个过程中,我们构建了一个简短的脚本,该脚本使用几个强大的第三方包 HTTPGumbo 从维基百科请求网页,将其解析为 HTML DOM,并从页面中提取所有内部链接。我们的脚本是一个完整的 Julia 项目的一部分,该项目使用 Pkg 来高效管理依赖项。

在本章中,我们将继续开发我们的游戏,实现完整的流程和游戏玩法。即使你不是经验丰富的开发者,也容易想象这样一个简单的游戏最终会有多个逻辑部分。我们可能有一个用于维基百科页面爬虫的模块,一个用于游戏本身,还有一个用于 UI(我们将在下一章中创建的 Web 应用)。将问题分解成更小的部分总是会使解决方案更简单。而且,这在编写代码时尤其如此——拥有小型、专业的函数,按责任分组,使得软件更容易推理、开发、扩展和维护。在本章中,我们将学习 Julia 的代码结构化构造,并讨论语言的一些更多关键元素:类型系统、构造函数、方法和多态。

在本章中,我们将涵盖以下主题:

  • 维基百科的六度分隔,游戏玩法

  • 使用模块组织我们的代码和从多个文件加载代码(所谓的 mixin 行为

  • 类型以及类型系统,这是 Julia 灵活性和性能的关键

  • 构造函数,特殊函数,允许我们创建我们类型的实例

  • 方法和多态,这是语言最重要的方面之一

  • 与关系型数据库交互(特别是 MySQL)

我希望你已经准备好深入研究了。

技术要求

Julia 的包生态系统正在持续发展中,并且每天都有新的包版本发布。大多数时候,这是一个好消息,因为新版本带来了新功能和错误修复。然而,由于许多包仍然处于测试版(版本 0.x),任何新版本都可能引入破坏性更改。因此,书中展示的代码可能会停止工作。为了确保你的代码能够产生与书中描述相同的结果,建议使用相同的包版本。以下是本章使用的外部包及其具体版本:

Cascadia@v0.4.0
Gumbo@v0.5.1
HTTP@v0.7.1
IJulia@v1.14.1
JSON@v0.20.0
MySQL@v0.7.0

为了安装特定版本的包,你需要运行:

pkg> add PackageName@vX.Y.Z 

例如:

pkg> add IJulia@v1.14.1

或者,你可以通过下载本章提供的 Project.toml 文件并使用pkg>实例化来安装所有使用的包:

julia> download("https://raw.githubusercontent.com/PacktPublishing/Julia-Programming-Projects/master/Chapter04/Project.toml", "Project.toml")
pkg> activate . 
pkg> instantiate

维基百科六度分隔游戏,游戏玩法

正如我们在上一章中看到的,维基百科六度分隔游戏是对六度分隔理论概念的戏仿——即所有生物(以及世界上几乎所有事物)彼此之间相隔六步或更少。例如,一个“朋友的朋友”的链条可以在最多六步内连接任何两个人。

对于我们自己的游戏,玩家的目标是连接任意两个给定的维基百科文章,通过六个或更少的其他维基百科页面。为了确保问题有解决方案(六度分隔理论尚未得到证实)并且确实存在从起始文章到结束文章的路径,我们将预先爬取完整路径。也就是说,我们将从一个随机的维基百科页面开始,这将是我们的起点,然后通过多个页面链接到我们的目的地,即结束文章。选择下一个链接页面的算法将是最简单的——我们只需随机选择任何内部链接。

为了使事情更有趣,我们还将提供难度设置——简单、中等或困难。这将影响起始页面和结束页面之间的距离。对于简单游戏,它们相隔两页,对于中等,四页,对于困难,六页。当然,这个逻辑并不非常严格。是的,直观上,我们可以这样说,相隔更远的文章之间关系较少,更难连接。但,玩家也可能找到更短的路径。尽管如此,我们不会担心这一点。

游戏还将允许玩家在最多步数内找不到解决方案时返回。

最后,如果玩家放弃,我们将添加一个选项来显示解决方案——从起始文章到目标文章的路径。

这听起来很令人兴奋——让我们写一些代码吧!

一些额外的要求

为了跟随本章,你需要以下内容:

  • 一个有效的 Julia 安装

  • 有互联网连接

  • 文本编辑器

组织我们的代码

到目前为止,我们主要在 REPL 中进行编码。最*,在上一章中,我们开始更多地依赖 IDE 来快速创建简短的 Julia 文件。

但是,随着我们的技能集的增长和越来越雄心勃勃的项目的发展,我们程序的复杂性也将增长。这反过来又会导致代码行数、逻辑和文件的增加——以及维护和理解所有这些的困难增加。正如著名的编码公理所说,代码被阅读的次数远多于被编写的次数——因此我们需要相应地规划。

每种语言在代码组织方面都有自己的哲学和工具集。在 Julia 中,我们有文件、模块和包。我们将在下一章中了解所有这些。

使用模块来驯服我们的代码

模块将相关的函数、变量和其他定义组合在一起。但,它们不仅仅是组织单元——它们是语言结构,可以理解为变量工作空间。它们允许我们定义变量和函数,而不用担心名称冲突。Julia 的 Module 是语言的基础之一——一个关键的架构和逻辑实体,有助于使代码更容易开发、理解和维护。我们将通过围绕模块构建我们的游戏来充分利用模块。

模块是通过使用 module <<name>>...end 结构定义的:

module MyModule 
# code here 
end

让我们开始一个新的 REPL 会话,看看一些例子。

假设我们想要编写一个函数来检索一个随机的维基百科页面——这是我们的游戏功能之一。我们可以称这个函数为 rand

如您所怀疑的,创建随机的 东西 是一项相当常见的任务,所以我们不是第一个考虑它的人。您可以亲自查看。在 REPL 中尝试这个:

julia> rand 
rand (generic function with 56 methods) 

结果,已经定义了 56 个 rand 方法。

这将使得添加我们自己的变体变得困难:

julia> function rand() 
           # code here 
       end 
error in method definition: function Base.rand must be explicitly imported to be extended 

我们尝试定义一个新的 rand 方法时引发了错误,因为它已经被定义并加载。

很容易看出,当我们选择函数名称时,这可能导致一个噩梦般的场景。如果所有定义的名称都生活在同一个工作空间中,我们就会陷入无休止的名称冲突,因为我们将耗尽为我们的函数和变量提供的相关名称。

Julia 的模块允许我们定义独立的工作空间,提供一种封装级别,将我们的变量和函数与其他人的变量和函数分开。通过使用模块,我们可以消除名称冲突。

模块是在 module...end 语言结构中定义的。尝试这个例子(在 REPL 中),我们在名为 MyModule 的模块中定义我们的 rand 函数:

julia> module MyModule 

      function rand() 
           println("I'll get a random Wikipedia page") 
      end 

      end 
Main.MyModule 
MyModule—and within it, a function called rand. Here, MyModule effectively encapsulates the rand function, which no longer clashes with Julia's Base.rand.

如您从其全名 Main.MyModule 中所见,我们新创建的模块实际上是在另一个名为 Main 的现有模块中添加的。这个模块 Main 是 REPL 中执行代码的默认模块。

为了访问我们新定义的函数,我们需要在 MyModule 中引用它,通过 点号连接

julia> MyModule.rand() 
I'll get a random wikipedia page 

定义模块

由于模块是为与较大的代码库一起使用而设计的,它们并不适合 REPL。因为一旦它们被定义,我们就不能通过额外的定义来扩展它们,我们必须重新输入并重新定义整个模块,因此最好使用一个完整的编辑器。

让我们创建一个新的文件夹来存放我们的代码。在其中,我们希望创建一个名为 modules/ 的新文件夹。然后,在 modules/ 文件夹中,添加三个文件—Letters.jlNumbers.jlmodule_name.jl

包含 Julia 代码的文件按照惯例使用 .jl 文件扩展名。

Julia 的高效 REPL 会话

为什么不使用 Julia 的文件管理能力来设置这个文件结构?让我们看看如何做这件事,因为它在我们的日常工作中会很有用。

记住,你可以在 REPL 中输入 ;,在行的开头,以触发 shell 模式。你的光标将从 julia> 变为 shell> 以确认上下文的变化。在 IJulia/Jupyter 中,你必须使用 ; 作为单元格中代码的前缀,以便在 shell 模式下执行。

现在,我们可以执行以下操作:

shell> mkdir modules # create a new dir called "modules" 
shell> cd modules # switch to the "modules" directory 

不要忘记 Julia 的 shell 模式会像它们直接在 OS 终端中运行一样调用命令——因此被调用的二进制文件必须存在于该*台上。mkdircd 在所有主要操作系统上都受支持,所以我们很安全。但是,当涉及到创建文件时,我们就无能为力了——在 Windows 上不可用 touch 命令。不过,没问题——在这种情况下,我们只需要调用具有相同名称的 Julia 函数。这将以*台无关的方式程序化地创建文件:

julia> for f in ["Letters.jl", "Numbers.jl", "module_name.jl"] 
           touch(f) 
       end 

如果你想要确保文件已被创建,请使用 readdir

julia> readdir() 
3-element Array{String,1}: 
 "Letters.jl" 
 "Numbers.jl" 
 "module_name.jl" 

请确保文件名与指示的完全一致,注意大小写。

Letters.jl in whatever default editor you have configured:
julia> edit("Letters.jl")  

如果默认编辑器不是你最喜欢的 Julia IDE,你可以通过设置 JULIA_EDITORVISUALEDITOR 环境变量之一来更改它,指向你选择的编辑器。例如,在我的 Mac 上,我可以使用以下命令获取 Atom 编辑器的路径:

shell> which atom 
/usr/local/bin/atom 

然后,我可以将 JULIA_EDITOR 设置如下:

julia> ENV["JULIA_EDITOR"] = "/usr/local/bin/atom" 

这三个变量有略微不同的用途,但在这个情况下,设置任何一个都将产生相同的效果——更改当前 Julia 会话的默认编辑器。不过,请记住,它们有不同的 权重,其中 JULIA_EDITOR 优先于 VISUAL,而 VISUAL 优先于 EDITOR

设置我们的模块

让我们首先编辑 Letters.jl,使其看起来像这样:

module Letters 

using Random 

export randstring 

const MY_NAME = "Letters" 

function rand() 
  Random.rand('A':'Z') 
end 

function randstring() 
  [rand() for _ in 1:10] |> join 
end 

include("module_name.jl") 

end 

在这里,我们定义了一个名为 Letters 的模块。在其中,我们添加了一个 rand 函数,该函数使用 Julia 的 Random.rand 来返回一个 AZ 之间的随机字母,形式为一个 Char。接下来,我们添加了一个名为 Letters.randstring 的函数,该函数返回一个由 10 个随机字符组成的 String。这个字符串是通过一个 Char[] 数组推导式(在 Julia 中 _ 变量名是合法的,并且按照惯例,它表示一个值未使用的变量)生成的,然后通过管道输入到 join 函数中,以返回字符串结果。

请注意,这是一个生成随机字符串的过于复杂的方法,因为 Julia 提供了 Random.randstring 函数。但是,在这个阶段,重要的是要抓住每一个机会来练习编写代码,而且我并不想浪费使用 Julia 的推导语法和管道操作符的机会。熟能生巧!

将我们的注意力转向代码的第一行,我们声明我们将 using Random——并指示编译器通过 export randstring 使 randstring 公开。最后,我们还声明了一个名为 MY_NAME 的常量,它指向 Letters 字符串(即模块本身的名称)。

模块的最后一行,include("module_name.jl"),将 module_name.jl 的内容加载到 Letters 中。include 函数通常用于交互式加载源代码,或将分成多个源文件的包中的文件组合在一起——我们很快就会看到它是如何工作的。

接下来,让我们编辑 Number.jl。它将有一个类似的 rand 函数,该函数将返回一个介于 11_000 之间的随机 Integer。它导出 halfrand 函数,该函数从 rand 获取一个值并将其除以 2。我们将除法的结果传递给 floor 函数,该函数将将其转换为最接*的小于或等于的值。而且,就像 Letters 一样,它还包括 module_name.jl

module Numbers 

using Random

export halfrand

const MY_NAME = "Numbers"

function rand() 
  Random.rand(1:1_000) 
end
function halfrand() 
  floor(rand() / 2) 
end

include("module_name.jl")
end 

因此,对于这两个模块,我们定义了一个 MY_NAME 常量。我们将通过编辑 module_name.jl 文件来引用它,使其看起来像这样:

function myname() 
  MY_NAME 
end 

代码返回常量的对应值,这取决于我们包含 module_name.jl 文件的实际模块。这说明了 Julia 的 mixin 行为,其中包含的代码表现得就像它被直接写入包含文件中一样。我们将在下一节中看到它是如何工作的。

模块引用

尽管我们现在只是正式讨论模块,但我们一直在使用它们。我们多次使用的 using 语句将其参数作为模块名称。这是一个关键的语言结构,告诉编译器将模块的定义引入当前作用域。在 Julia 中引用其他模块中定义的函数、变量和类型是编程的常规部分——例如,访问第三方包提供的功能,围绕通过 using 将其主模块引入作用域。但是,using 并不是 Julia 武器库中的唯一工具。我们还有几个其他命令可供使用,例如 importincludeexport

using 指令允许我们引用其他模块导出的函数、变量、类型等。这告诉 Julia 使模块的导出定义在当前工作区中可用。如果这些定义是由模块的作者导出的,我们可以调用它们而无需在它们前面加上模块的名称(在函数名称前加上模块名称表示完全限定名称)。但是,请注意,这是一把双刃剑——如果两个使用的模块导出了具有相同名称的函数,仍然必须使用完全限定名称来访问这些函数——否则 Julia 将抛出异常,因为它不知道我们指的是哪个函数。

至于 import,它在某种程度上是相似的,因为它也将另一个模块的定义引入作用域。但是,它在两个重要方面有所不同。首先,调用 import MyModule 仍然需要在定义前加上模块的名称,从而避免潜在的名称冲突。其次,如果我们想用新方法扩展其他模块中定义的函数,必须使用 import

另一方面,include 在概念上是不同的。它用于将一个文件的内容评估到当前上下文中(即当前模块的 全局 作用域)。这是一种通过提供类似 mixin 的行为来重用代码的方法,正如我们之前所看到的。

被包含的文件在模块的全局作用域中评估的事实是一个非常重要的点。这意味着,即使我们在函数体中包含一个文件,文件的内容也不会在函数的作用域内评估,而是在模块的作用域内评估。为了看到这一点,让我们在我们的 modules/ 文件夹中创建一个名为 testinclude.jl 的文件。编辑 testinclude.jl 并添加以下代码行:

somevar = 10

现在,如果你在 REPL 或 IJulia 中运行以下代码,你就能明白我的意思:

julia> function testinclude() 
             include("testinclude.jl") 
             println(somevar) 
       end 

julia> testinclude() 
10 

显然,一切正常。testinclude.jl 文件被包含进来,somevar 变量被定义了。然而,somevar 并不是在 testinclude 函数中创建的,而是在 Main 模块中的全局变量。我们可以很容易地看到这一点,因为我们可以直接访问 somevar 变量:

julia> somevar 
10 

请记住这种行为,因为它可能导致在全局作用域中暴露变量时出现难以理解的错误。

最后,模块的作者使用 export 来暴露定义,就像公共接口一样。正如我们所见,导出的函数和变量是通过模块的用户通过 using 引入作用域的。

设置 LOAD_PATH

让我们看看一些示例,这些示例说明了在处理模块时作用域规则。请打开一个新的 Julia REPL。

我们在前几章中多次看到了 using 语句,现在我们理解了它的作用——就是将另一个模块及其定义(变量、函数、类型)引入作用域。让我们用我们新创建的模块来试一试:

julia> using Letters 
ERROR: ArgumentError: Package Letters not found in current path: 
- Run `Pkg.add("Letters")` to install the Letters package. 

哎呀,出现了一个异常!Julia 告诉我们它不知道在哪里找到 Letters 模块,并建议我们使用 Pkg.add("Letters") 来安装它。但是,由于 Pkg.add 只与已注册的包一起工作,而我们还没有将我们的模块发布到 Julia 的注册表中,这不会有所帮助。结果是我们只需要告诉 Julia 我们代码的位置。

当被要求通过 using 将一个模块引入作用域时,Julia 会检查一系列路径以查找相应的文件。这些查找路径存储在一个名为 LOAD_PATHVector 中——我们可以通过使用 push! 函数将我们的 modules/ 文件夹添加到这个集合中:

julia> push!(LOAD_PATH, "modules/") 
4-element Array{String,1}: 
 "@" 
 "@v#.#" 
 "@stdlib" 
 "modules/" 

你的输出可能会有所不同,但重要的是在调用 push! 之后,LOAD_PATH 集合现在有一个额外的元素,表示 modules/ 文件夹的路径。

为了让 Julia 能够将模块的名称与其对应的文件匹配,文件必须与模块具有完全相同的名称,加上 .jl 扩展名。一个文件可以包含多个模块,但 Julia 将无法通过文件名自动找到额外的模块。

关于模块命名的命名约定是使用驼峰式命名法。因此,我们最终会在名为Letters.jl的文件中定义一个名为Letters的模块,或者在一个名为WebSockets.jl的文件中定义一个名为WebSockets的模块。

使用using加载模块

现在我们已经将我们的文件夹添加到LOAD_PATH中,我们就可以使用我们的模块了:

julia> using Letters 

到目前为止,发生了两件事:

  • 所有导出的定义现在都可以在 REPL 中直接调用,在我们的例子中,是randstring

  • 未导出的定义可以通过Lettersdotting into来访问——例如,Letters.rand()

让我们试试:

julia> randstring() # has been exported and is directly accessible 
"TCNXFLUOUU" 
julia> myname() # has not been exported so it's not available in the REPLERROR: UndefVarError: myname not defined
 julia> Letters.myname() # but we can access it under the Letters namespace 
"Letters"
 julia> Letters.rand() # does not conflict with Base.rand 
'L': ASCII/Unicode U+004c (category Lu: Letter, uppercase) 

我们可以使用names函数查看模块导出了什么:

julia> names(Letters) 
2-element Array{Symbol,1}: 
 :Letters 
 :randstring 

如果我们想获取一个模块的所有定义,无论是否导出,names函数接受一个名为all的第二个参数,一个Boolean

julia> names(Letters, all = true) 
11-element Array{Symbol,1}: 
 # output truncated 
 :Letters 
 :MY_NAME 
 :eval 
 :myname 
 :rand 
 :randstring 

我们可以轻松地识别我们定义的变量和函数。

正如我们所见,例如,myname并没有直接引入作用域,因为它在Letters中没有导出。但结果是,如果我们明确告诉 Julia 使用该函数,我们仍然可以得到类似导出的行为:

julia> using Letters: myname
julia> myname() # we no longer need to "dot into" Letters.myname() 
"Letters" 

如果我们想直接将同一模块中的多个定义引入作用域,我们可以传递一个以逗号分隔的名称列表:

julia> using Letters: myname, MY_NAME 

使用import加载模块

现在,让我们看看import函数的效果,使用Numbers

julia> import Numbers
julia> names(Numbers) 
2-element Array{Symbol,1}: 
 :Numbers 
 :halfrand
julia> halfrand() 
ERROR: UndefVarError: halfrand not defined 

我们可以看到,与using不同,import函数不会将导出的定义引入作用域

然而,显式导入一个定义本身会将其直接引入作用域,不考虑它是否被导出:

julia> import Numbers.halfrand, Numbers.MY_NAME 

这段代码等同于以下代码:

julia> import Numbers: halfrand, MY_NAME 

julia> halfrand() 
271.0 

使用include加载模块

当开发独立的程序,如我们现在正在做的,操作LOAD_PATH效果很好。但是,对于包开发者来说,这种方法不可用。在这种情况下——以及所有由于某种原因使用LOAD_PATH不是选项的情况——加载模块的常见方式是通过包含它们的文件。

例如,我们可以将我们的Letters模块包含在 REPL 中,如下所示(启动一个新的 REPL 会话):

julia> include("modules/Letters.jl") 
Main.Letters 

这将读取并评估当前作用域中modules/Letters.jl文件的内容。结果,它将在我们的当前模块Main中定义Letters模块。但是,这还不够——在这个阶段,Letters中的任何定义都没有被导出:

julia> randstring() 
ERROR: UndefVarError: randstring not defined 

我们需要将它们引入作用域:

julia> using Letters 
ERROR: ArgumentError: Package Letters not found in current path: 
- Run `Pkg.add("Letters")` to install the Letters package.

别再了!刚才发生了什么?当使用include与模块时,这是一个重要的区别。正如我们刚才说的,Letters模块被包含在当前模块Main中,因此我们需要相应地引用它:

julia> using Main.Letters 

julia> randstring() 
"QUPCDZKSAH" 

我们也可以通过使用相对路径来引用这种嵌套模块层次结构。例如,一个点.代表current module。因此,之前的Main.Letters嵌套可以表示为.Letters——这正是同一件事:

julia> using .Letters 

类似地,我们可以使用两个点..来引用父模块,三个点用于父模块的父模块,依此类推。

模块嵌套

正如我们所看到的,有时我们程序的逻辑会要求一个模块必须成为另一个模块的一部分,从而有效地嵌套它们。我们在开发自己的包时特别喜欢使用这种方法。组织包的最佳方式是暴露一个顶层模块,并在其中包含所有其他定义(函数、变量和其他模块)(以封装功能)。一个例子应该有助于澄清这些内容。

让我们做一个改变——在Letters.jl文件中,在说include("module_name.jl")的行下面,继续添加另一行——include("Numbers.jl")

通过这个变化,Numbers模块将实际上在Letters模块内部定义。为了访问嵌套模块的功能,我们需要点进到必要的深度:

julia> using .Letters 

julia> Letters.Numbers.halfrand() 
432.5 

设置我们游戏的架构

让我们为我们的游戏找一个家——创建一个名为sixdegrees/的新文件夹。我们将用它来组织我们的游戏文件。每个文件将包含一个模块,每个模块将打包相关的功能。我们将利用 Julia 的自动加载功能,这意味着每个模块的文件名将与模块的名称相同,加上.jl扩展名。

然而,一旦我们进入sixdegrees/文件夹,我们首先需要通过Pkg初始化我们的项目——这样我们就可以使用 Julia 的依赖项管理功能:

julia> mkdir("sixdegrees") 
"sixdegrees" 

julia> cd("sixdegrees/") 

julia> ] # go into pkg mode 

(v1.0) pkg> activate . 

(sixdegrees) pkg> 

我们将使用HTTPGumbo包,所以在处理依赖项时,现在添加它们是个好主意:

(sixdegrees) pkg> add HTTP Gumbo 

接下来我们需要的是一个用于存放与维基百科相关代码的容器——一个封装了请求文章和提取内部 URL 功能的模块。我们已经在第三章,“设置维基游戏”中编写的webcrawler.jl文件中完成了代码的第一个迭代。现在,我们只需要创建一个Wikipedia模块,并用webcrawler.jl的内容填充它。

sixdegrees文件夹内,创建一个名为Wikipedia.jl的新文件。用以下代码设置它:

module Wikipedia
using HTTP, Gumbo 

const RANDOM_PAGE_URL = "https://en.m.wikipedia.org/wiki/Special:Random" 

export fetchrandom, fetchpage, articlelinks 

function fetchpage(url) 
  response = HTTP.get(url) 
  if response.status == 200 && length(response.body) > 0 
    String(response.body) 
  else 
    "" 
  end 
end 

function extractlinks(elem, links = String[]) 
  if  isa(elem, HTMLElement) && tag(elem) == :a && in("href", collect(keys(attrs(elem)))) 
        url = getattr(elem, "href") 
        startswith(url, "/wiki/") && ! occursin(":", url) && push!(links, url) 
  end 
  for child in children(elem) 
    extractlinks(child, links) 
  end 
  unique(links) 
end 

function fetchrandom() 
  fetchpage(RANDOM_PAGE_URL) 
end 

function articlelinks(content) 
  if ! isempty(content) 
    dom = Gumbo.parsehtml(content) 

    links = extractlinks(dom.root) 
  end 
end

end

前面的代码应该看起来很熟悉,因为它与webcrawler.jl共享了大部分逻辑。但是,有一些重要的变化。

首先,我们将一切包裹在一个module声明中。

请注意一个非常重要的约定:在 Julia 中,我们不会在模块内部缩进代码,因为这会导致整个文件缩进,从而影响可读性。

在第三行,我们原本有 Julia 维基百科条目的链接,现在我们定义了一个String常量RANDOM_PAGE_URL,它指向一个特殊的维基百科 URL,该 URL 返回一个随机文章。我们还切换到了维基百科网站的移动版本,如en.m.子域名所示。使用移动页面会使我们的工作更简单,因为它们更简单,标记也更少。

fetchpage 函数中,我们不再寻找 Content-Length 标头,而是检查 response.body 属性的 length。我们这样做是因为请求特殊的随机维基百科页面会进行重定向,在这个过程中,Content-Length 标头会被丢弃。

我们还替换了文件底部的部分逻辑。我们不再自动获取 Julia 的维基百科页面并将内部链接列表输出到屏幕上,我们现在定义了两个额外的函数:fetchrandomarticlelinks。这些函数将是 Wikipedia 模块的公共接口,并且通过 export 语句公开。fetchrandom 函数确实如其名所示——它调用 fetchpage 函数,传入 RANDOM_PAGE_URL 常量,实际上是从随机维基百科页面获取。articlelinks 返回一个表示链接文章的字符串数组。

最后,我们移除了 LINKS 常量——应该避免使用全局变量。extractlinks 函数已经相应地重构,现在接受第二个参数,links,一个 StringVector,它在递归过程中用于维护状态。

检查我们的代码

让我们确保在这次重构之后,我们的代码仍然按预期工作。Julia 默认带有单元测试功能,我们将在第十一章 创建 Julia 包中探讨这些内容。现在,我们将按照老方法来做,手动运行代码并检查输出。

我们将在 sixdegrees/ 文件夹内添加一个新文件,命名为 six_degrees.jl。从其名称来看,您可以猜测它将是一个纯 Julia 文件,而不是一个模块。我们将使用它来编排游戏的加载:

using Pkg 
pkg"activate ." 

include("Wikipedia.jl") 
using .Wikipedia 

fetchrandom() |> articlelinks |> display 

代码简单且简洁——我们使用 Pkg 激活当前项目。然后,我们将 Wikipedia.jl 文件包含到当前模块中,然后要求编译器将 Wikipedia 模块引入作用域。最后,我们使用之前讨论过的 fetchrandomarticlelinks 来从随机维基百科页面检索文章 URL 列表并显示。

是时候运行我们的代码了!在 REPL 中,确保您已经 cdsixdegrees 文件夹并执行:

julia> include("six_degrees.jl") 
21-element Array{String,1}: 
 "/wiki/Main_Page" 
 "/wiki/Arena" 
 "/wiki/Saskatoon,_Saskatchewan" 
 "/wiki/South_Saskatchewan_River" 
 "/wiki/New_York_Rangers" 
# ... output omitted ... #
Array{String,1} with entries that start with /wiki/.

或者,您可以在 Visual Studio Code 和 Atom 中使用运行代码或运行文件选项。以下是 Atom 运行 six_degrees.jl 文件的情况:

图片

构建我们的维基百科爬虫 - 第二部分

我们的代码按预期运行,重构并整洁地打包到一个模块中。然而,在继续之前,我还有一个东西想让我们重构。我对我们的 extractlinks 函数并不特别满意。

首先,它天真地遍历了所有的 HTML 元素。例如,假设我们还想提取页面的标题——每次我们想要处理不是链接的内容时,我们都需要再次遍历整个文档。这将非常消耗资源,并且运行速度会变慢。

其次,我们正在重新发明轮子。在第三章 设置 Wiki 游戏 中,我们说 CSS 选择器是 DOM 解析的通用语言。如果我们使用 CSS 选择器的简洁语法和由专用库提供的底层优化,我们将从中获得巨大的好处。

幸运的是,我们不需要寻找太远就能找到这种功能。Julia 的Pkg系统提供了对Cascadia的访问,这是一个本地的 CSS 选择器库。而且,它的一大优点是它与Gumbo协同工作。

为了使用 Cascadia,我们需要将其添加到我们项目的依赖列表中:

(sixdegrees) pkg> add Cascadia

接下来,告诉 Julia 我们将使用它——修改Wikipedia.jl,使其第三行如下所示:

using HTTP, Gumbo, Cascadia

Cascadia的帮助下,我们现在可以重构extractlinks函数,如下所示:

function extractlinks(elem) 
  map(eachmatch(Selector("a[href^='/wiki/']:not(a[href*=':'])"), elem)) do e 
    e.attributes["href"] 
  end |> unique 
end 

让我们更仔细地看看这里发生的一切。首先引起注意的是Selector函数。这是由Cascadia提供的,它构建一个新的 CSS 选择器对象。传递给它作为唯一参数的字符串是一个 CSS 选择器,其内容为——所有具有以'/wiki/'开头且不包含列(:)的href属性的<a>元素。

Cascadia还导出了eachmatch方法。更准确地说,它是扩展了我们之前看到的带有正则表达式的现有Base.eachmatch方法。这提供了一个熟悉的接口——我们将在本章后面的方法部分看到如何扩展方法。Cascadia.eachmatch函数返回一个匹配选择器的Vector元素。

一旦我们检索到匹配的元素集合,我们就将其传递给map函数。map函数是函数式编程工具箱中最常用的工具之一。它接受一个函数f和一个集合c作为其参数——并通过将f应用于每个元素来转换集合c,返回修改后的集合作为结果。其定义如下:

map(f, c...) -> collection  
map function, it's true. But it is, in fact, the exact same function invocation, except with a more readable syntax, provided by Julia's blocks.

使用块

因为在 Julia 中,函数是一等语言构造,它们可以被引用和操作,就像任何其他类型的变量一样。它们可以作为其他函数的参数传递,或者可以作为其他函数调用的结果返回。将另一个函数作为其参数或返回另一个函数作为其结果的函数称为高阶函数

让我们通过一个简单的map示例来看看。我们将取一个IntVector,并将一个函数应用于其集合的每个元素,该函数将值加倍。你可以在新的 REPL 会话(或在配套的 IJulia 笔记本)中跟随:

julia> double(x) = x*2 
double (generic function with 1 method) 

julia> map(double, [1, 2, 3, 5, 8, 13]) 
6-element Array{Int64,1}: 
  2 
  4 
  6 
 10 
 16 
 26 
double function as the argument of the higher-order function map. As a result, we got back the Vector, which was passed as the second argument, but with all the elements doubled.

那都是好的,但是不得不定义一个函数只是为了将其作为另一个函数的一次性参数是不方便的,而且有点浪费。出于这个原因,支持函数式特性的编程语言,包括 Julia,通常支持 匿名函数。匿名函数,或称为 lambda,是一个没有绑定到标识符的函数定义。

我们可以将前面的 map 调用重写为使用匿名函数,该函数通过使用箭头 -> 语法现场定义:

julia> map(x -> x*2, [1, 2, 3, 5, 8, 13]) 
6-element Array{Int64,1}: 
  2 
  4 
  6 
 10 
 16 
 26

在定义中,x -> x*2,箭头左边的 x 代表传递给函数的参数,而 x*2 代表函数体。

太棒了!我们没有必要单独定义 double 就达到了相同的结果。但是,如果我们需要使用更复杂的函数呢?例如,注意以下内容:

julia> map(x -> 
           if x % 2 == 0 
                  x * 2 
           elseif x % 3 == 0 
                  x * 3 
           elseif x % 5 == 0 
                  x * 5 
           else 
                  x 
           end,  
      [1, 2, 3, 5, 8, 13]) 

这很难理解!因为 Julia 允许我们缩进我们的代码,我们可以增强这个示例的可读性,使其更加易于接受,但结果仍然远远不够好。

由于这些情况经常发生,Julia 提供了用于定义匿名函数的块语法。所有将另一个函数作为其 第一个 参数的函数都可以使用块语法。对这种调用的支持已经内置于语言中,因此你不需要做任何事情——只要函数是第一个位置参数,你的函数就会自动支持它。为了使用它,我们在调用高阶函数时跳过传递第一个参数——而是在参数列表的末尾,在参数元组之外,添加一个 do...end 块。在这个块内部,我们定义我们的 lambda。

因此,我们可以将前面的示例重写如下:

map([1, 2, 3, 5, 8, 13]) do x 
       if x % 2 == 0 
              x * 2 
       elseif x % 3 == 0 
              x * 3 
       elseif x % 5 == 0 
              x * 5 
        else 
              x 
        end 
 end 

阅读起来更加清晰!

实现游戏玩法

我们现在对维基百科解析器相当有信心,添加 Cascadia 大大简化了代码。现在是时候考虑实际的游戏玩法了。

最重要的是,游戏的精髓是创建谜题——要求玩家从初始文章找到通往结束文章的路径。我们之前决定,为了确保两篇文章之间确实存在路径,我们将预先爬取所有页面,从第一页到最后一页。为了从一个页面导航到下一个页面,我们将简单地随机选择一个内部 URL。

我们还提到了包括难度设置。我们将使用常识假设,即起始文章和结束文章之间的链接越多,它们的主题就越不相关;因此,识别它们之间路径的难度就越大,导致更具有挑战性的难度级别。

好吧,是时候开始编码了!首先,在 sixdegrees/ 文件夹内创建一个新文件。命名为 Gameplay.jl 并复制粘贴以下内容:

module Gameplay 

using ..Wikipedia 

export newgame 

const DIFFICULTY_EASY = 2 
const DIFFICULTY_MEDIUM = 4 
const DIFFICULTY_HARD = 6 

function newgame(difficulty = DIFFICULTY_HARD) 
  articles = [] 

  for i in 1:difficulty 
    article = if i == 1 
      fetchrandom() 
    else 
      rand(articles[i-1][:links]) |> Wikipedia.fetchpage 
    end 

article_data = Dict(:content => article, 
  :links => articlelinks(article)) 
    push!(articles, article_data) 
  end 

  articles 
end 

end 

Gamplay.jl 定义了一个新的 module 并将 Wikipedia 带入作用域。在这里,你可以看到我们如何通过使用 .. 在父作用域中引用 Wikipedia 模块。然后它定义了三个常量,这些常量将难度设置映射到分离度(分别命名为 DIFFICULTY_EASYDIFFICULTY_MEDIUMDIFFICULTY_HARD)。

然后,它定义了一个名为 newgame 的函数,该函数接受一个难度参数,默认设置为困难。在函数的主体中,我们循环的次数等于难度值。在每次迭代中,我们检查当前的分离度——如果是第一篇文章,我们调用 fetchrandom 来启动爬取过程。如果不是第一篇文章,我们从先前爬取的文章的链接列表中随机选择一个链接(rand(articles[i-1][:links]))。然后我们将此 URL 传递给 fetchpage。在讨论条件语句时,我们了解到在 Julia 中 if/else 语句返回最后一个评估表达式的值。我们可以看到它在这里得到了很好的应用,评估的结果被存储在 article 变量中。

一旦我们获取了文章,我们将其内容及其链接存储在一个名为 article_dataDict 中。然后,article_data 被添加到 articles 数组中。在其最后一行,newgame 函数返回包含所有步骤(从第一个到最后一个)的 articles 向量。此函数也被导出。

这并不太难!但是,有一个小问题。如果你现在尝试运行代码,它将会失败。原因是文章链接是 relative 的。这意味着它们不是完全限定的 URL;它们看起来像 /wiki/Some_Article_Title。当 HTTP.jl 发起请求时,它需要包含协议、链接和域名。但别担心,在 Wikipedia.jl 中修复这个问题很容易。请切换你的编辑器到 Wikipedia 模块,并将 const RANDOM_PAGE_URL 行替换为以下三行:

const PROTOCOL = "https://" 
const DOMAIN_NAME = "en.m.wikipedia.org" 
const RANDOM_PAGE_URL = PROTOCOL * DOMAIN_NAME * "/wiki/Special:Random" 

我们将随机页面 URL 分解为其组成部分——协议、域名和剩余的相对路径。

我们将使用类似的方法在获取文章时将相对 URL 转换为绝对 URL。为此,更改 fetchpage 的主体,并将其作为第一行代码添加以下内容:

url = startswith(url, "/") ? PROTOCOL * DOMAIN_NAME * url : url 

在这里,我们检查 url 参数——如果它以 "/" 开头,这意味着它是一个相对 URL,因此我们需要将其转换为它的绝对形式。正如你可以看到的,我们使用了三元运算符。

我们现在的代码应该可以正常工作,但将这个 PROTOCOL * DOMAIN_NAME * url 散布到我们的游戏中有点像 code smell。让我们将其抽象成一个函数:

function buildurl(article_url) 
    PROTOCOL * DOMAIN_NAME * article_url 
end 

在编程术语中,code smell 指的是违反基本设计原则并负面影响的实践。它本身不是一个 bug,但它表明设计中的弱点可能会增加未来出现错误或失败的风险。

Wikipedia.jl 文件现在应该看起来像这样:

module Wikipedia 

using HTTP, Gumbo, Cascadia 

const PROTOCOL = "https://" 
const DOMAIN_NAME = "en.m.wikipedia.org" 
const RANDOM_PAGE_URL = PROTOCOL * DOMAIN_NAME * "/wiki/Special:Random" 

export fetchrandom, fetchpage, articlelinks 

function fetchpage(url) 
  url = startswith(url, "/") ? buildurl(url) : url 
  response = HTTP.get(url) 

  if response.status == 200 && length(response.body) > 0 
    String(response.body) 
  else  
    "" 
  end 
end 

function extractlinks(elem) 
  map(eachmatch(Selector("a[href^='/wiki/']:not(a[href*=':'])"), elem)) do e 
    e.attributes["href"] 
  end |> unique 
end 

function fetchrandom() 
  fetchpage(RANDOM_PAGE_URL) 
end 

function articlelinks(content) 
  if ! isempty(content) 
    dom = Gumbo.parsehtml(content) 

    links = extractlinks(dom.root) 
  end 
end 

function buildurl(article_url) 
  PROTOCOL * DOMAIN_NAME * article_url 
end 

end 

完成细节

我们的游戏玩法发展得很好。只剩下几个部件了。在思考我们的游戏用户界面时,我们希望展示游戏的进度,指出玩家已经导航过的文章。为此,我们需要文章的标题。如果我们还能包括一张图片,那会让我们的游戏看起来更漂亮。

幸运的是,我们现在使用 CSS 选择器,因此提取缺失的数据应该轻而易举。我们只需要将以下内容添加到Wikipedia模块中:

import Cascadia: matchFirst 

function extracttitle(elem) 
  matchFirst(Selector("#section_0"), elem) |> nodeText 
end 

function extractimage(elem) 
  e = matchFirst(Selector(".content a.image img"), elem) 
  isa(e, Void) ? "" : e.attributes["src"] 
end 

extracttitleextractimage函数将从我们的文章页面检索相应的内容。在两种情况下,因为我们只想选择一个元素,即主页标题和第一张图片,所以我们使用Cascadia.matchFirstmatchFirst函数不是由Cascadia公开暴露的——但因为它非常有用,所以我们import它。

#section_0选择器标识主页标题,一个<h1>元素。而且,因为我们需要提取其<h1>...</h1>标签之间的文本,我们调用Cascadia提供的nodeText方法。

你可以在以下屏幕截图(显示 Safari 检查器中的 Wikipedia 页面的主要标题)中看到,如何识别所需的 HTML 元素以及如何通过检查页面源代码和相应的 DOM 元素来选择它们的 CSS 选择器。HTML 属性id="section_0"对应于#section_0CSS 选择器:

对于extractimage,我们寻找主要文章图片,表示为".content a.image img"选择器。由于并非所有页面都有它,我们检查是否确实得到了一个有效的元素。如果页面没有图片,我们将得到一个Nothing实例,称为nothing。这是一个重要的构造——nothingNothing的单例实例,表示没有对象,对应于其他语言中的NULL。如果我们确实得到了一个img元素,我们提取其src属性的值,即图片的 URL。

这里是另一个 Wikipedia 截图,其中我标记了我们要针对的图像元素。旗帜是 Wikipedia 的澳大利亚页面上的第一张图片——一个完美的匹配:

接下来,我们可以扩展Gameplay.newgame函数,以处理新的功能和值。但到目前为止,这感觉并不合适——太多的Wikipedia逻辑会泄露到Gameplay模块中,使它们耦合;这是一个危险的反模式。相反,让我们让数据的提取和文章的设置,即Dict,成为Wikipedia的全权责任,完全封装逻辑。让Gameplay.newgame函数看起来如下所示:

function newgame(difficulty = DIFFICULTY_HARD) 
  articles = [] 

  for i in 1:difficulty  
    article = if i == 1 
                fetchrandom() 
              else  
                rand(articles[i-1][:links]) |> Wikipedia.fetchpage 
              end 
    push!(articles, articleinfo(article)) 
  end 

  articles 
end 

然后,更新Wikipedia模块如下所示:

module Wikipedia 

using HTTP, Gumbo, Cascadia 
import Cascadia: matchFirst 

const PROTOCOL = "https://" 
const DOMAIN_NAME = "en.m.wikipedia.org" 
const RANDOM_PAGE_URL = PROTOCOL * DOMAIN_NAME * "/wiki/Special:Random" 

export fetchrandom, fetchpage, articleinfo 

function fetchpage(url) 
  url = startswith(url, "/") ? buildurl(url) : url 

  response = HTTP.get(url) 

  if response.status == 200 && length(response.body) > 0 
    String(response.body) 
  else  
    "" 
  end 
end 

function extractlinks(elem) 
  map(eachmatch(Selector("a[href^='/wiki/']:not(a[href*=':'])"), elem)) do e 
    e.attributes["href"] 
  end |> unique 
end 

function extracttitle(elem) 
  matchFirst(Selector("#section_0"), elem) |> nodeText 
end 

function extractimage(elem) 
  e = matchFirst(Selector(".content a.image img"), elem) 
  isa(e, Nothing) ? "" : e.attributes["src"] 
end 

function fetchrandom() 
  fetchpage(RANDOM_PAGE_URL) 
end 

function articledom(content) 
  if ! isempty(content) 
    return Gumbo.parsehtml(content) 
  end 

  error("Article content can not be parsed into DOM") 
end 

function articleinfo(content) 
  dom = articledom(content) 

  Dict( :content => content,  
        :links => extractlinks(dom.root),  
        :title => extracttitle(dom.root),  
        :image => extractimage(dom.root) 
  ) 
end 

function buildurl(article_url) 
  PROTOCOL * DOMAIN_NAME * article_url 
end 

end 

文件有几处重要更改。我们移除了articlelinks函数,并添加了articleinfoarticledom。新的articledom函数使用Gumbo解析 HTML 并生成 DOM,这非常重要,DOM 只解析一次。我们不希望在每次提取元素类型时都解析 HTML 到 DOM,就像如果我们保留之前的articlelinks函数那样。至于articleinfo,它负责设置一个包含所有相关信息的文章Dict——内容、链接、标题和图片。

我们可以通过修改six_degrees.jl文件来进行代码的测试运行,如下所示:

using Pkg 
pkg"activate ." 

include("Wikipedia.jl") 
include("Gameplay.jl") 

using .Wikipedia, .Gameplay 

for article in newgame(Gameplay.DIFFICULTY_EASY) 
  println(article[:title]) 
end 

我们开始一个新的游戏,它包含两篇文章(Gameplay.DIFFICULTY_EASY),并且对于每一篇文章,我们都会显示其标题。我们可以通过在 REPL 会话中运行它来看到它的实际效果,通过julia> include("six_degrees.jl"),或者简单地通过在 Visual Studio Code 或 Atom 中运行文件。下面是 REPL 中的样子:

julia> include("six_degrees.jl") 
Miracle Bell 
Indie pop  

还有一件事

我们的测试运行显示我们的难度设置有一个小故障。我们应该在起点之后爬取一定数量的文章。我们的初始文章不应计入。这个问题非常容易解决。在Gameplay.newgame中,我们需要将for i in 1:difficulty替换为for i in 1:difficulty+1(注意最后的+1)。现在,如果我们再次尝试,它将按预期工作:

julia> include("six_degrees.jl") 
John O'Brien (Australian politician) 
Harlaxton, Queensland 
Ballard, Queensland 

学习 Julia 的类型系统

我们的游戏运行得非常顺利,但有一件事我们可以改进——将我们的文章信息存储为Dict。Julia 的字典非常灵活和强大,但它们并不适合所有情况。Dict是一个通用的数据结构,它针对搜索、删除和插入操作进行了优化。这里我们都不需要这些——我们的文章具有固定的结构,并且创建后数据不会改变。这是一个非常适合使用对象和面向对象编程OOP)的用例。看来是时候学习类型了。

Julia 的类型系统是语言的核心——它无处不在,定义了语言的语法,并且是 Julia 性能和灵活性的驱动力。Julia 的类型系统是动态的,这意味着在程序可用的实际值之前,我们对类型一无所知。然而,我们可以通过使用类型注解来利用静态类型的好处——表明某些值具有特定的类型。这可以大大提高代码的性能,并增强可读性,简化调试。

讨论 Julia 语言而不提及类型是不可能的。确实,到目前为止,我们已经看到了许多原始类型——IntegerFloat64BooleanChar等等。在学习各种数据结构,如ArrayDict或元组时,我们也接触到了类型。这些都是语言内置的,但结果是 Julia 使得创建我们自己的类型变得非常容易。

定义我们自己的类型

Julia 支持两种类型的类别——原始类型和复合类型。原始类型是一个具体类型,其数据由普通的位组成。复合类型是一组命名字段,其实例可以被视为单个值。在许多语言中,复合类型是唯一一种用户可定义的类型,但 Julia 允许我们声明自己的原始类型,而不仅仅是提供一组固定的内置类型。

我们在这里不会讨论定义原始类型,但你可以在官方文档中了解更多信息,网址为docs.julialang.org/en/v1/manual/types/

为了表示我们的文章,我们最好使用一个不可变的复合类型。一旦我们的文章对象被创建,其数据就不会改变。不可变的复合类型是通过struct关键字后跟一个字段名称块来引入的:

struct Article 
    content 
    links 
    title 
    image 
end 

由于我们没有为字段提供类型信息——也就是说,我们没有告诉 Julia 我们希望每个字段是什么类型——它们将默认为任何类型,允许存储任何类型的值。但是,由于我们已经知道我们想要存储什么数据,我们将极大地从限制每个字段的类型中受益。::运算符可以用来将类型注解附加到表达式和变量上。它可以读作“是一个实例”。因此,我们定义Article类型如下:

struct Article 
    content::String 
    links::Vector{String} 
    title::String 
    image::String 
end 

所有字段都是String类型,除了links,它是一个一维的Array,也称为Vector{String}

类型注解可以提供重要的性能优势——同时消除一类与类型相关的错误。

构造类型

创建Article类型的新对象是通过将Article类型名称像函数一样应用来实现的。参数是该字段的值:

julia> julia = Article( 
           "Julia is a high-level dynamic programming language", 
           ["/wiki/Jeff_Bezanson", "/wiki/Stefan_Karpinski",  
            "/wiki/Viral_B._Shah", "/wiki/Alan_Edelman"], 
           "Julia (programming language)", 
           "/220px-Julia_prog_language.svg.png" 
       ) 
Article("Julia is a high-level dynamic programming language", ["/wiki/Jeff_Bezanson", "/wiki/Stefan_Karpinski", "/wiki/Viral_B._Shah", "/wiki/Alan_Edelman"], "Julia (programming language)", "/220px-Julia_prog_language.svg.png") 

可以使用标准的点表示法访问新创建对象的字段:

julia> julia.title 
"Julia (programming language)" 

由于我们声明我们的类型为不可变的,所以值是只读的,因此它们不能被更改:

julia> julia.title = "The best programming language, period" 
ERROR: type Article is immutable 

我们的Article类型定义不会允许我们更改julia.title属性。但是,不可变性不应该被忽视,因为它确实带来了相当大的优势,如官方 Julia 文档所述:

  • 它可能更有效。某些结构可以有效地打包到数组中,在某些情况下,编译器能够避免分配不可变对象。

  • 无法违反类型构造函数提供的不变性。

  • 使用不可变对象的代码可能更容易推理。

但是,这并不是全部的故事。一个不可变对象可以拥有引用可变对象的字段,例如,比如links,它指向一个Array{String, 1}。这个数组仍然是可变的:

julia> push!(julia.links, "/wiki/Multiple_dispatch") 
5-element Array{String,1}: 
 "/wiki/Jeff_Bezanson" 
 "/wiki/Stefan_Karpinski" 
 "/wiki/Viral_B._Shah" 
 "/wiki/Alan_Edelman" 
 "/wiki/Multiple_dispatch" 

我们可以通过尝试向底层集合推送一个额外的 URL 来改变links属性,看到没有错误发生。如果一个属性指向一个可变类型,那么这个类型可以被修改,只要它的类型保持不变:

julia> julia.links = [1, 2, 3] 
MethodError: Cannot `convert` an object of type Int64 to an object of type String 

我们不允许更改links字段的类型——Julia 试图适应并尝试将我们提供的值从Int转换为String,但失败了。

可变复合类型

同样地(并且同样简单),我们也可以构造可变复合类型。我们唯一需要做的是使用mutable struct语句,而不是仅仅使用struct

julia> mutable struct Player 
           username::String 
           score::Int 
       end 

我们的Player对象应该是可变的,因为我们需要在每次游戏后更新score属性:

julia> me = Player("adrian", 0) 
Player("adrian", 0) 

julia> me.score += 10 
10 

julia> me 
Player("adrian", 10) 

类型层次结构和继承

就像所有实现 OOP 特性的编程语言一样,Julia 允许开发者定义丰富和表达性的类型层次结构。然而,与大多数 OOP 语言不同的是,有一个非常重要的区别——在 Julia 中,只有层次结构中的最终(上层)类型可以被实例化。所有它的父类型只是类型图中的节点,我们无法创建它们的实例。它们是抽象类型,使用abstract类型关键字定义:

julia> abstract type Person end 

我们可以使用<:运算符来表示一个类型是现有父类型的子类型:

julia> abstract type Mammal end 
julia> abstract type Person <: Mammal end 
julia> mutable struct Player <: Person 
           username::String 
           score::Int 
       end 

或者,在另一个例子中,这是 Julia 的数值类型层次结构:

abstract type Number end 
abstract type Real     <: Number end 
abstract type AbstractFloat <: Real end 
abstract type Integer  <: Real end 
abstract type Signed   <: Integer end 
abstract type Unsigned <: Integer end 

超类型不能实例化的事实可能看起来很有限,但它们有一个非常强大的作用。我们可以定义接受超类型作为参数的函数,实际上接受所有其子类型:

julia> struct User <: Person 
           username::String 
           password::String 
       end 

julia> sam = User("sam", "password") 
User("sam", "password") 

julia> function getusername(p::Person) 
           p.username 
      end 

julia> getusername(me) 
"adrian" 

julia> getusername(sam) 
"sam" 

julia> getusername(julia) 
ERROR: MethodError: no method matching getusername(::Article) 
Closest candidates are: 
  getusername(::Person) at REPL[25]:2 

在这里,我们可以看到我们如何定义了一个getusername函数,它接受一个(抽象)类型参数,Person。由于UserPlayer都是Person的子类型,它们的实例被接受为参数。

类型联合

有时,我们可能希望允许一个函数接受一组不一定属于同一类型层次结构的类型。当然,我们可以允许函数接受任何类型,但根据用例,可能希望严格限制参数到一个定义良好的类型子集。对于这种情况,Julia 提供了类型联合

类型联合是一种特殊的抽象类型,它包括使用特殊Union函数构造的所有其参数类型的实例:

julia> GameEntity = Union{Person,Article} 
Union{Article, Person} 

在这里,我们定义了一个新的类型联合,GameEntity,它包括两种类型——PersonArticle。现在,我们可以定义知道如何处理GameEntities的函数:

julia> function entityname(e::GameEntity) 
           isa(e, Person) ? e.username : e.title 
       end 
entityname (generic function with 1 method) 

julia> entityname(julia) 
"Julia (programming language)" 

julia> entityname(me) 
"adrian" 

使用文章类型

我们可以将我们的代码重构,以消除通用的Dict数据结构,并用专门的Article复合类型来表示我们的文章。

让我们在我们的sixdegrees/工作文件夹中创建一个新的文件,命名为Articles.jl。通过输入相应的module声明来编辑文件。然后,添加我们类型的定义并将其export

module Articles 

export Article 

struct Article 
  content::String 
  links::Vector{String} 
  title::String 
  image::String 
end 

end 

我们本可以将Article类型定义添加到Wikipedia.jl文件中,但很可能会增长,因此最好将它们分开。

另一点需要注意的是,moduletype 都是 Julia 实体,它们在相同的作用域中被加载。因此,我们不能同时使用 Article 这个名字来命名 moduletype——否则会出现名称冲突。然而,复数形式的 Articles 是一个很好的模块名称,因为它将封装处理一般文章的逻辑,而 Article 类型代表一个文章实体——因此使用单数形式。

然而,由于概念上 Article 对象引用了一个维基百科页面,它应该是 Wikipedia 命名空间的一部分。这很简单,我们只需要将其包含到 Wikipedia 模块中。在 import Cascadia: matchFirst 行之后添加以下内容:

include("Articles.jl") 
using .Articles 

我们包含了 Articles 模块文件并将其带入作用域。

接下来,在同一个 Wikipedia.jl 文件中,我们需要修改 articleinfo 函数。请确保它如下所示:

function articleinfo(content) 
  dom = articledom(content) 
  Article(content,  
          extractlinks(dom.root),  
          extracttitle(dom.root),  
          extractimage(dom.root)) 
end 

我们现在不是创建一个通用的 Dict 对象,而是实例化一个 Article 的实例。

我们还需要对 Gameplay.jl 进行一些修改,以使用 Article 类型而不是 Dict。它现在应该看起来像这样:

module Gameplay 

using ..Wikipedia, ..Wikipedia.Articles 

export newgame 

const DIFFICULTY_EASY = 2 
const DIFFICULTY_MEDIUM = 4 
const DIFFICULTY_HARD = 6 

function newgame(difficulty = DIFFICULTY_HARD) 
  articles = Article[] 

  for i in 1:difficulty+1 
    article = if i == 1 
                fetchrandom() 
              else  
                rand(articles[i-1].links) |> fetchpage 
              end 
    push!(articles, articleinfo(article)) 
  end 

  articles 
end 

end 

注意,在第三行我们将 Wikipedia.Articles 带入作用域。然后,在 newgame 函数中,我们将 articles 数组初始化为 Vector{Article} 类型。接着,我们更新 for 循环中的代码来处理 Article 对象——rand(articles[i-1].links)

最后的更改在 six_degrees.jl 中。由于 newgame 现在返回一个 Article 对象的向量而不是 Dict,我们通过访问 title 字段来打印标题:

using Pkg 
pkg"activate ." 

include("Wikipedia.jl") 
include("Gameplay.jl") 

using .Wikipedia, .Gameplay 

articles = newgame(Gameplay.DIFFICULTY_EASY) 

for article in articles 
  println(article.title) 
end 

新的测试运行应该确认所有工作如预期(由于我们正在拉取随机文章,所以你的输出将不同):

julia> include("six_degrees.jl") 
Sonpur Bazari 
Bengali language 
Diacritic 

内部构造函数

外部构造函数(我们作为函数调用 type)是一个默认构造函数,我们为所有字段提供值,并按正确的顺序返回相应类型的实例。但是,如果我们想提供额外的构造函数,可能施加某些约束、执行验证或者只是更用户友好呢?为此,Julia 提供了 内部构造函数。我有一个很好的用例。

我并不特别喜欢我们的 Article 构造函数——它需要太多的参数,并且必须按正确的顺序传递。很难记住如何实例化它。我们之前学过关键字参数——提供一个接受关键字参数的替代构造函数会非常棒。内部构造函数正是我们所需要的。

内部构造函数与外部构造函数非常相似,但有两大主要区别:

  • 它们是在类型声明块的内部声明的,而不是像正常方法那样在块外部声明。

  • 它们可以访问一个特殊的本地存在函数 new,该函数创建相同类型的对象。

另一方面,外部构造函数有一个明显的限制(按设计)——我们可以创建尽可能多的构造函数,但它们只能通过调用现有的内部构造函数来实例化对象(它们没有访问 new 函数的权限)。这样,如果我们定义了实现某些业务逻辑约束的内部构造函数,Julia 保证外部构造函数不能绕过这些约束

我们使用关键字参数的内部构造函数看起来是这样的:

Article(; content = "", links = String[], title = "", image = "") = new(content, links, title, image) 

注意到 ; 的使用,它将空的位置参数列表与关键字参数列表分开。

这个构造函数允许我们使用关键字参数来实例化 Article 对象,我们可以按任何顺序提供这些参数:

julia = Article( 
          title = "Julia (programming language)", 
          content = "Julia is a high-level dynamic programming language", 
          links = ["/wiki/Jeff_Bezanson", "/wiki/Stefan_Karpinski",  
                  "/wiki/Viral_B._Shah", "/wiki/Alan_Edelman"], 
          image = "/220px-Julia_prog_language.svg.png" 
        ) 

然而,有一个小问题。当我们没有提供任何内部构造函数时,Julia 提供默认的一个。但是,如果定义了任何内部构造函数,就不再提供默认构造函数方法——假设我们已经提供了所有必要的内部构造函数。在这种情况下,如果我们想获取带有位置参数的默认构造函数,我们必须自己定义它作为一个内部构造函数:

Article(content, links, title, image) = new(content, links, title, image) 

Articles.jl 文件的最终版本现在应该是以下内容,包含两个内部构造函数:

module Articles 

export Article 

struct Article 
  content::String 
  links::Vector{String} 
  title::String 
  image::String 

  Article(; content = "", links = String[], title = "", image = "") = new(content, links, title, image) 
  Article(content, links, title, image) = new(content, links, title, image) end 

end 

值得指出的是,在这种情况下,我们的关键字构造函数也可以作为一个外部构造函数添加,并定义在 struct...end 主体之外。你使用哪种构造函数是一个架构决策,必须根据具体情况逐个案例进行考虑,考虑到内部构造函数和外部构造函数之间的差异。

方法

如果你来自面向对象编程的背景,你可能会注意到在我们的类型讨论中一个非常有趣的方面。与其他语言不同,Julia 中的对象不定义行为。也就是说,Julia 的类型只定义字段(属性),但不封装函数。

原因在于 Julia 对 多重调度 的实现,这是语言的一个独特特性。

多重调度在官方文档中的解释如下:

"当对一个函数应用时,选择执行哪个方法的过程称为调度。Julia 允许调度过程根据提供的参数数量以及所有函数参数的类型来选择调用函数的哪个方法。这与传统的面向对象语言不同,在传统的面向对象语言中,调度仅基于第一个参数[...]。使用一个函数的所有参数来选择应该调用的方法,而不是仅使用第一个参数,这被称为多重调度。多重调度对于数学代码特别有用,因为它使得人为地将操作归因于一个参数比其他任何参数更有意义的情况变得没有意义。"

Julia 允许我们定义函数,为某些参数类型的组合提供特定的行为。一个函数可能行为的定义被称为方法。方法定义的签名可以注解以指示参数的类型,而不仅仅是它们的数量,并且可以提供多个方法定义。一个例子将有所帮助。

假设我们之前定义了Player类型,如下所示:

julia> mutable struct Player 
           username::String 
           score::Int 
       end 

在这里,我们看到相应的getscore函数:

julia> function getscore(p) 
           p.score 
       end 
getscore (generic function with 1 method) 

到目前为止,一切顺利。但是,随着我们的游戏取得惊人的成功,我们可能会添加一个应用商店来提供应用内购买。这将使我们定义一个Customer类型,该类型可能有一个同名的credit_score字段,用于存储他们的信用评分:

julia> mutable struct Customer 
           name::String 
           total_purchase_value::Float64 
           credit_score::Float64 
       end 

当然,我们需要一个相应的getscore函数:

julia> function getscore(c) 
           c.credit_score 
      end 
getscore (generic function with 1 method) 

现在,Julia 将如何知道使用哪个函数呢?它不会。因为这两个函数都被定义为接受任何类型的参数,最后定义的函数覆盖了之前的函数。我们需要根据它们的参数类型对两个getscore声明进行特殊化:

julia> function getscore(p::Player) 
           p.score 
       end 
getscore (generic function with 1 method) 

julia> function getscore(c::Customer) 
           c.credit_score 
       end 
getscore (generic function with 2 methods) 

如果你仔细查看每个函数声明的输出,你会看到一些有趣的东西。在定义getscore(p::Player)之后,它说getscore (generic function with 1 method)。但是,在定义getscore(c::Customer)之后,它显示getscore (generic function with 2 methods)。所以现在,我们已经为getscore函数定义了两种方法,每种方法都针对其参数类型进行了特殊化。

但是,如果我们添加以下内容呢?

julia> function getscore(t::Union{Player,Customer}) 
           isa(t, Player) ? t.score : t.credit_score 
       end 
getscore (generic function with 3 methods) 

或者,我们可以注意以下可能添加的内容:

julia> function getscore(s) 
            if in(:score, fieldnames(typeof(s))) 
            s.score 
       elseif in(:credit_score, fieldnames(typeof(s))) 
            s.credit_score 
       else 
            error("$(typeof(s)) does not have a score property") 
       end 
end 
getscore (generic function with 4 methods) 

你能猜到在调用getscore时,使用PlayerCustomerArticle对象将使用哪些方法吗?我会给你一个提示:当一个函数应用于一组特定的参数时,将调用适用于这些参数的最具体的方法。

如果我们想查看给定参数集调用的方法,我们可以使用@which

julia> me = Player("adrian", 10) 
Player("adrian", 10) 

julia> @which getscore(me) 
getscore(p::Player) in Main at REPL[58]:2

对于Customer类型也是如此:

julia> sam = Customer("Sam", 72.95, 100) 
Customer("Sam", 72.95, 100.0) 

julia> @which getscore(sam) 
getscore(c::Customer) in Main at REPL[59]:2 

我们可以看到最专业的方法是如何被调用的——getscore(t::Union{Player,Customer}),这是一个更通用的方法,实际上从未被使用。

然而,以下情况又如何呢?

julia> @which getscore(julia) 
getscore(s) in Main at REPL[61]:2 

传递Article类型将调用getscore的最后一个定义,即接受Any类型参数的定义:

julia> getscore(julia) 
ERROR: Article does not have a score property 

由于Article类型没有scorecredit_score属性,我们定义的ErrorException正在被抛出。

要找出为函数定义了哪些方法,请使用methods()

julia> methods(getscore) 
# 4 methods for generic function "get_score": 
getscore(c::Customer) in Main at REPL[59]:2 
getscore(p::Player) in Main at REPL[58]:2 
getscore(t::Union{Customer, Player}) in Main at REPL[60]:2 
getscore(s) in Main at REPL[61]:2 

与关系型数据库一起工作

我们的网页爬虫性能相当出色——使用 CSS 选择器非常高效。但是,就目前而言,如果我们不同游戏会话中遇到相同的维基百科文章,我们不得不多次获取、解析和提取其内容。这是一个耗时且资源密集的操作——更重要的是,如果我们只存储第一次获取的文章信息,我们就可以轻松消除这一操作。

我们可以使用 Julia 的序列化功能,我们之前已经看到过了,但由于我们正在构建一个相当复杂的游戏,添加数据库后端将对我们有所帮助。除了存储文章数据外,我们还可以持久化有关玩家、分数、偏好等信息。

我们已经看到了如何与 MongoDB 交互。然而,在这种情况下,关系型数据库是更好的选择,因为我们将与一系列相关实体一起工作:文章、游戏(引用文章)、玩家(引用游戏)等等。

Julia 的包生态系统为与关系数据库交互提供了广泛的选择,从通用的 ODBC 和 JDBC 库到针对主要后端(MySQL/MariaDB、SQLite 和 Postgres 等)的专用包。对于我们的游戏,我们将使用 MySQL。如果你系统上还没有安装 MySQL,请按照dev.mysql.com/downloads/mysql/上的说明进行操作。或者,如果你使用 Docker,你可以从hub.docker.com/r/library/mysql/获取官方的 MySQL Docker 镜像。

在 Julia 这边,(sixdegrees) pkg>add MySQL就是我们需要添加 MySQL 支持的所有操作。确保你在sixdegrees/项目内添加 MySQL。你可以通过查看pkg>光标的前缀来确认这一点;它应该看起来像这样:(sixdegrees)pkg>。如果不是这种情况,只需在确保你处于sixdegrees/文件夹内的情况下执行pkg> activate .

添加 MySQL 支持

当与 SQL 数据库一起工作时,将 DB 相关逻辑抽象出来,避免在所有代码库中散布 SQL 字符串和数据库特定命令是一个好的做法。这将使我们的代码更具可预测性和可管理性,并在我们需要更改或升级数据库系统时提供一层安全的抽象。我是一个使用 ORM 系统的忠实粉丝,但在这个案例中,作为一个学习工具,我们将自己添加这个功能。

连接到数据库

首先,让我们指导我们的应用程序连接到并断开与我们的 MySQL 数据库的连接。让我们通过在其对应的文件中添加一个新的Database模块来扩展我们的游戏:

module Database 

using MySQL 

const HOST = "localhost" 
const USER = "root" 
const PASS = "" 
const DB = "six_degrees" 

const CONN = MySQL.connect(HOST, USER, PASS, db = DB) 

export CONN 

disconnect() = MySQL.disconnect(CONN) 

atexit(disconnect) 

end 
HOST, USER, and PASS constants with your correct MySQL connection info. Also, please don't forget to create a new, empty database called six_degrees—otherwise the connection will fail. I suggest using utf8 for the encoding and utf8_general_ci for the collation, in order to accommodate all the possible characters we might get from Wikipedia.

调用MySQL.connect返回一个连接对象。我们需要它来与数据库交互,因此我们将通过CONN常量来引用它:

julia> Main.Database.CONN 
MySQL Connection 
------------ 
Host: localhost 
Port: 3306 
User: root 
DB:   six_degrees 

由于我们的代码的各个部分都需要访问这个连接对象以对数据库进行查询,我们将其export。同样重要的是,我们需要设置一些清理机制,以便在完成操作后自动断开与数据库的连接。我们定义了一个可以手动调用的disconnect函数。但是,如果我们确保清理函数能够自动调用,那就更安全了。Julia 提供了一个atexit函数,它可以将一个无参数函数f注册为在进程退出时调用。atexit钩子以后进先出LIFO)的顺序调用。

设置我们的文章模块

下一步是向Article模块添加几个更多函数,以启用数据库持久化和检索功能。由于它将需要访问我们的数据库连接对象,让我们给它访问Database模块的权限。我们还将想要使用MySQL函数。因此,在export Article行下,添加using..Database, MySQL

接下来,我们将添加一个createtable方法。这将是一个一次性函数,用于创建相应的数据库表。我们使用这个方法而不是直接在 MySQL 客户端中键入CREATE TABLE查询,以便有一个一致且可重复的创建(重新)创建表的方式。一般来说,我更喜欢使用完整的数据库迁移库,但现在,最好保持简单(你可以在en.wikipedia.org/wiki/Schema_migration上阅读有关模式迁移的内容)。

不再拖延,这是我们的函数:

function createtable() 
  sql = """ 
    CREATE TABLE `articles` ( 
      `title` varchar(1000), 
      `content` text, 
      `links` text, 
      `image` varchar(500), 
      `url` varchar(500), 
      UNIQUE KEY `url` (`url`) 
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8 
  """ 

  MySQL.execute!(CONN, sql) 
end 

在这里,我们定义了一个sql变量,它引用了一个CREATE TABLE查询,形式为一个String。该表将有四个列,对应于我们的Article类型的四个字段。然后还有一个第五列,url,它将存储文章的维基百科 URL。我们将通过 URL 来识别文章——因此,我们在url列上添加了一个唯一索引。

函数的末尾,我们将查询字符串传递给MySQL.execute!以在数据库连接上运行。请将createtable定义添加到Articles模块的末尾(在模块内,在关闭end之前)。

现在,让我们看看它是如何工作的。在sixdegrees/文件夹中打开一个新的 REPL 会话,并运行以下命令:

julia> using Pkg 
julia> pkg"activate ." 
julia> include("Database.jl") 
julia> include("Articles.jl") 
julia> using .Articles 
julia> Articles.createtable() 

就这样,我们的表已经准备好了!

工作流程应该是很清晰的——我们确保加载了我们的项目依赖项,包含了Database.jlArticles.jl文件,将Articles引入作用域,然后调用了它的createtable方法。

添加持久化和检索方法

我们提到,当一篇文章被获取并解析后,我们希望将其数据存储到数据库中。因此,在获取文章之前,我们首先会检查我们的数据库。如果文章之前已经被持久化,我们将检索它。如果没有,我们将执行原始的获取和解析工作流程。我们使用url属性来唯一标识文章。

让我们先添加Articles.save(a::Article)方法来持久化文章对象:

function save(a::Article) 
  sql = "INSERT IGNORE INTO articles (title, content, links, image, url) VALUES (?, ?, ?, ?, ?)" 
  stmt = MySQL.Stmt(CONN, sql) 
  result = MySQL.execute!(stmt, [a.title, a.content, JSON.json(a.links), a.image, a.url]) 
end 

在这里,我们使用MySQL.Stmt来创建一个 MySQL 预编译语句。查询本身非常简单,使用了 MySQL 的INSERT IGNORE语句,确保只有当没有与相同url的文章时,才会执行INSERT操作。如果已经存在具有相同url的文章,则查询将被忽略。

预处理语句接受一个特殊格式的查询字符串,其中实际值被占位符替换,占位符由问号?表示。然后我们可以通过将相应的值数组传递给MySQL.execute!来执行预处理语句。值直接从article对象传递,除了links。由于这代表一个更复杂的数据结构,一个Vector{String},我们首先使用JSON序列化它,并将其作为字符串存储在 MySQL 中。为了访问JSON包中的函数,我们必须将其添加到我们的项目中,所以请在 REPL 中执行(sixdegrees) pkg> add JSON

预处理语句提供了一种安全地执行查询的方法,因为值会被自动转义,消除了 MySQL 注入攻击的常见来源。在我们的情况下,MySQL 注入不太令人担忧,因为我们不接受用户生成的输入。但是,这种方法仍然很有价值,可以避免由于不当转义引起的插入错误。

接下来,我们需要一个检索方法。我们将称之为find。作为它的唯一属性,它将接受一个形式为String的文章 URL。它将返回一个Article对象的Array。按照惯例,如果没有找到相应的文章,数组将是空的:

function find(url) :: Vector{Article} 
  articles = Article[] 

  result = MySQL.query(CONN, "SELECT * FROM `articles` WHERE url = '$url'") 

  isempty(result.url) && return articles 

  for i in eachindex(result.url) 
    push!(articles, Article(result.content[i], JSON.parse(result.links[i]), result.title[i], 
                            result.image[i], result.url[i])) 
  end 

  articles 
end 

在这个函数的声明中,我们可以看到另一个 Julia 特性:返回值类型。在常规函数声明function find(url)之后,我们附加了:: Vector{Article}。这限制了find的返回值为一个Article数组。如果我们的函数不会返回那个值,将会抛出错误。

代码的其余部分,虽然非常紧凑,但功能相当多。首先,我们创建了一个articles向量,其中包含Article对象,这将是我们函数的返回值。然后,我们通过MySQL.query方法对 MySQL 数据库执行一个SELECT查询,尝试找到匹配url的行。查询的结果存储在result变量中,它是一个NamedTupleresult NamedTuple中的每个字段都引用了一个与数据库列同名的值数组)。接下来,我们查看我们的查询结果result以查看是否得到了任何东西——我们选择采样result.url字段——如果它是空的,这意味着我们的查询没有找到任何东西,我们可以直接退出函数,返回一个空的articles向量。

另一方面,如果result.url确实包含条目,这意味着我们的查询至少返回了一行;因此,我们使用eachindex遍历result.url数组,并在每次迭代中用相应的值构建一个Article对象。最后,我们将这个新的Article对象push!到返回的articles向量中,循环结束后。

将所有这些放在一起

最后,我们需要更新代码的其余部分,以适应我们迄今为止所做的更改。

首先,我们需要更新 Article 类型以添加额外的 url 字段。我们需要在字段列表和两个构造函数中使用它。以下是 Articles.jl 的最终版本:

module Articles 

export Article, save, find 

using ...Database, MySQL, JSON 

struct Article 
  content::String 
  links::Vector{String} 
  title::String 
  image::String 
  url::String 

  Article(; content = "", links = String[], title = "", image = "", url = "") = 
        new(content, links, title, image, url) 
  Article(content, links, title, image, url) = new(content, links, title, image, url) 
end 

function find(url) :: Vector{Article} 
  articles = Article[] 

  result = MySQL.query(CONN, "SELECT * FROM `articles` WHERE url = '$url'") 

  isempty(result.url) && return articles 

  for i in eachindex(result.url) 
    push!(articles, Article(result.content[i], JSON.parse(result.links[i]), result.title[i], 
                            result.image[i], result.url[i])) 
  end 

  articles 
end 

function save(a::Article) 
  sql = "INSERT IGNORE INTO articles (title, content, links, image, url) VALUES (?, ?, ?, ?, ?)" 
  stmt = MySQL.Stmt(CONN, sql) 
  result = MySQL.execute!(stmt, [ a.title, a.content, JSON.json(a.links), a.image, a.url]) 
end 

function createtable() 
  sql = """ 
    CREATE TABLE `articles` ( 
      `title` varchar(1000), 
      `content` text, 
      `links` text, 
      `image` varchar(500), 
      `url` varchar(500), 
      UNIQUE KEY `url` (`url`) 
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8 
  """ 

  MySQL.execute!(CONN, sql) 
end 

end  

我们还需要对 Wikipedia.jl 进行一些重要的更改。首先,我们将从 Wikipedia.articleinfo 中删除 Article 实例化,因为现在创建 Article 对象也应考虑数据库的持久化和检索。相反,我们将返回表示文章数据的元组:

function articleinfo(content) 
  dom = articledom(content) 
  (content, extractlinks(dom.root), extracttitle(dom.root), extractimage(dom.root)) 
end 

我们现在可以添加一个新函数 persistedarticle,它将接受文章内容和文章 URL 作为参数。它将实例化一个新的 Article 对象,将其保存到数据库中,并返回它。从某种意义上说,persistedarticle 可以被视为数据库支持的构造函数,因此得名:

function persistedarticle(article_content, url) 
  article = Article(articleinfo(article_content)..., url) 
  save(article) 

  article 
end 

在这里,你可以看到 splat 操作符 ... 的实际应用——它将 articleinfo 结果 Tuple 分解为其对应的元素,以便它们可以作为单独的参数传递给 Article 构造函数。

此外,我们必须处理一个小的复杂问题。当我们开始新游戏并调用 /wiki/Special:Random URL 时,维基百科会自动将重定向到一个随机文章。当我们获取页面时,我们得到重定向页面的内容,但我们没有其 URL。

因此,我们需要做两件事。首先,我们需要检查我们的请求是否已被重定向,如果是的话,获取重定向 URL。为了做到这一点,我们可以检查 response.parent 字段。在重定向的情况下,response.request.parent 对象将被设置,并将呈现一个 headers 集合。该集合将包括一个 "Location" 项——这正是我们所追求的。

其次,我们还需要返回页面的 HTML 内容以及 URL。这很简单——我们将返回一个元组。

这里是更新后的 fetchpage 函数:

function fetchpage(url) 
  url = startswith(url, "/") ? buildurl(url) : url 
  response = HTTP.get(url) 
  content = if response.status == 200 && length(response.body) > 0 
              String(response.body) 
            else 
              "" 
            end 
  relative_url = collect(eachmatch(r"/wiki/(.*)$",  
(response.request.parent == nothing ? url : Dict(response.request.parent.headers)["Location"])))[1].match 

  content, relative_url 
end 

注意,我们还使用 eachmatch 从绝对 URL 中提取相应的相对 URL 部分。

这里是整个 Wikipedia.jl 文件:

module Wikipedia 
using HTTP, Gumbo, Cascadia 
import Cascadia: matchFirst 

include("Articles.jl") 
using .Articles 

const PROTOCOL = "https://" 
const DOMAIN_NAME = "en.m.wikipedia.org" 
const RANDOM_PAGE_URL = PROTOCOL * DOMAIN_NAME * "/wiki/Special:Random" 

export fetchrandom, fetchpage, articleinfo, persistedarticle 

function fetchpage(url) 
  url = startswith(url, "/") ? buildurl(url) : url 
  response = HTTP.get(url) 
  content = if response.status == 200 && length(response.body) > 0 
              String(response.body) 
            else 
              "" 
            end 
  relative_url = collect(eachmatch(r"/wiki/(.*)$", (response.request.parent == nothing ? url : Dict(response.request.parent.headers)["Location"])))[1].match 

  content, relative_url 
end 

function extractlinks(elem) 
  map(eachmatch(Selector("a[href^='/wiki/']:not(a[href*=':'])"), elem)) do e 
    e.attributes["href"] 
  end |> unique 
end 

function extracttitle(elem) 
  matchFirst(Selector("#section_0"), elem) |> nodeText 
end 

function extractimage(elem) 
  e = matchFirst(Selector(".content a.image img"), elem) 
  isa(e, Nothing) ? "" : e.attributes["src"] 
end 

function fetchrandom() 
  fetchpage(RANDOM_PAGE_URL) 
end 

function articledom(content) 
  if ! isempty(content) 
    return Gumbo.parsehtml(content) 
  end 

  error("Article content can not be parsed into DOM") 
end 

function articleinfo(content) 
  dom = articledom(content) 
  (content, extractlinks(dom.root), extracttitle(dom.root), extractimage(dom.root)) 
end 

function persistedarticle(article_content, url) 
  article = Article(articleinfo(article_content)..., url) 
  save(article) 

  article 
end 

function buildurl(article_url) 
  PROTOCOL * DOMAIN_NAME * article_url 
end 

end 

现在,让我们专注于 Gameplay.jl。我们需要更新 newgame 函数以利用 Wikipedia 模块中新可用的方法:

module Gameplay 

using ..Wikipedia, ..Wikipedia.Articles 

export newgame 

const DIFFICULTY_EASY = 2 
const DIFFICULTY_MEDIUM = 4 
const DIFFICULTY_HARD = 6 

function newgame(difficulty = DIFFICULTY_HARD) 
  articles = Article[] 

  for i in 1:difficulty+1 
    article = if i == 1 
                article = persistedarticle(fetchrandom()...) 
              else 
                url = rand(articles[i-1].links) 
                existing_articles = Articles.find(url) 

                article = isempty(existing_articles) ? persistedarticle(fetchpage(url)...) : existing_articles[1] 
              end 

    push!(articles, article) 
  end 

  articles 
end 

end 

如果是第一篇文章,我们获取一个随机页面并持久化其数据。否则,我们从之前爬取的页面中随机选择一个 URL 并检查是否存在相应的文章。如果没有,我们获取该页面,确保它也被持久化到数据库中。

最后,我们进入应用程序的入口点,即 six_degrees.jl 文件,需要看起来像这样:

using Pkg 
pkg"activate ." 

include("Database.jl") 
include("Wikipedia.jl") 
include("Gameplay.jl") 

using .Wikipedia, .Gameplay 

articles = newgame(Gameplay.DIFFICULTY_EASY) 

for article in articles 
  println(article.title) 
end 

最后的测试运行应该确认一切正常:

$ julia six_degrees.jl                                                                                                                                                               
Hillary Maritim 
Athletics at the 2000 Summer Olympics - Men's 400 metres hurdles 
Zahr-el-Din El-Najem 

在终端中使用 julia 二进制文件运行 six_degrees.jl 文件将输出三个维基百科文章标题。我们可以检查数据库以确认数据已被保存:

之前爬取的三个页面的数据已安全持久化。

摘要

恭喜,这真是一次相当漫长的旅程!我们学习了三个关键的 Julia 概念——模块、类型及其构造函数,以及方法。我们将所有这些知识应用于开发我们的“维基百科六度分隔”游戏后端,在这个过程中,我们看到了如何与 MySQL 数据库交互,持久化和检索我们的Article对象。

在下一章的结尾,我们将有机会享受我们辛勤工作的果实:在我们为我们的“维基百科六度分隔”后端添加了 Web UI 之后,我们将通过玩几轮来放松。看看你是否能打败我的最佳成绩!

第五章:为维基游戏添加 Web 用户界面

开发我们游戏的后端是一个相当有学习经验的过程。这个坚实的基础将为我们带来很多好处——模块化的方法将使我们能够轻松地将读取-评估-打印循环REPL)应用程序转换为 Web 应用程序,而我们对类型的理解在处理 Julia 的 Web 堆栈及其丰富的分类法时将证明是无价的。

我们现在正进入游戏开发旅程的最后阶段——为维基百科六度分隔构建 Web 用户界面。由于构建一个功能齐全的 Web 应用程序并非易事,这部分内容将专门用于这项任务。在这个过程中,我们将学习以下主题:

  • Julia 的 Web 堆栈;即HTTP包及其主要组件——ServerRouterHandlerFunctionResponse

  • 构建一个 Web 应用程序以利用HTTP并集成现有 Julia 模块

  • 通过定义将 URL 映射到 Julia 函数的路由来在 Web 上公开功能

  • 启动一个 Web 服务器来处理用户请求并向客户端发送适当的响应

本章的结尾带来了一份酷炫的回报——我们的游戏将准备就绪,我们将玩几轮维基百科六度分隔

技术要求

Julia 的包生态系统正在持续发展中,并且每天都有新的包版本发布。大多数时候这是个好消息,因为新版本带来了新功能和错误修复。然而,由于许多包仍在测试版(版本 0.x)中,任何新版本都可能引入破坏性更改。因此,书中展示的代码可能无法正常工作。为了确保您的代码将产生与书中描述相同的结果,建议使用相同的包版本。以下是本章使用的外部包及其特定版本:

Cascadia@v0.4.0
Gumbo@v0.5.1
HTTP@v0.7.1
IJulia@v1.14.1

为了安装特定版本的包,您需要运行:

pkg> add PackageName@vX.Y.Z 

例如:

pkg> add IJulia@v1.14.1

或者,您也可以通过下载本章提供的Project.toml文件并使用pkg>实例化来安装所有使用的包,如下所示:

julia> download("https://raw.githubusercontent.com/PacktPublishing/Julia-Programming-Projects/master/Chapter05/Project.toml", "Project.toml")
pkg> activate . 
pkg> instantiate

游戏计划

我们的项目已经进入最后阶段——Web 用户界面。让我们先讨论一下规格说明;在我们可以进行实现之前,我们需要制定蓝图。

玩家将从着陆页开始。这将显示规则,并提供启动新游戏的选择,让用户选择难度级别。从这个起点开始,玩家将被重定向到新游戏页面。在这里,考虑到所选的难度级别,我们将通过获取我们在上一章中编写的算法来启动一个新的游戏会话。一旦我们选择了代表维基百科“六度分隔”的文章,我们将显示一个带有游戏目标的标题——起始和结束文章的标题。我们还将显示第一篇文章的内容,从而启动游戏。当玩家点击这篇文章中的链接时,我们必须相应地检查玩家是否找到了结束文章并赢得了游戏。如果没有,渲染新文章并增加所采取的步骤数。

我们还需要一个区域来显示游戏的进度——当前会话中查看的文章、总共采取了多少步骤,以及一种导航方式,允许玩家在发现自己走错路时返回并重新考虑他们的选择。因此,我们需要存储玩家的导航历史。最后,提供一种解决谜题的选项会很好——当然,作为结果,玩家将输掉游戏。

规范中非常重要的一部分是,在无状态的浏览器请求和服务器响应之间,在浏览维基百科文章时,我们需要某种机制来允许我们保持游戏的状态,也就是说,检索带有相应数据的游戏——难度、路径(文章)、进度、导航历史、所采取的步骤数等等。这将通过在每个游戏会话开始时创建一个唯一的游戏标识符,并将其作为 URL 的一部分与每个请求一起传递来实现。

了解朱莉娅的 Web 栈

朱莉娅的包生态系统长期以来为构建 Web 应用提供了各种库。其中一些最成熟的包括 HttpServerMuxWebSocketsJuliaWebAPI(仅举几个例子;这个列表并不全面)。但随着朱莉娅版本 1 的生态系统稳定下来,社区投入了大量努力开发了一个新的包,简单地称为 HTTP。它提供了一个 Web 服务器、一个 HTTP 客户端(我们已经在之前的章节中使用它从维基百科获取网页),以及各种使 Web 开发更简单的实用工具。我们将了解关键的 HTTP 模块,如 ServerRouterRequestResponseHandlerFunction,并将它们用于良好的用途。

从一个简单的例子——Hello World 开始

让我们来看一个使用 HTTP 服务器栈的简单示例。这将帮助我们理解在深入探讨将我们的游戏公开在网络上这一更复杂问题之前的基础构建块。

如果你跟随着上一章的内容,你应该已经安装了 HTTP 包。如果没有,你知道该怎么做——在 Julia 的 REPL 中运行 pkg> add HTTP

现在,在你的电脑上的某个位置,创建一个名为 hello.jl 的新文件。由于这将是一个仅包含一个文件的简单软件,因此不需要定义模块。以下是完整的代码,全部八行,全部的辉煌。我们将在下一部分中讲解它们:

using HTTP, Sockets
const HOST = ip"0.0.0.0"
const PORT = 9999
router = HTTP.Router()
server = HTTP.Server(router)
HTTP.register!(router, "/", HTTP.HandlerFunction(req -> HTTP.Messages.Response(200, "Hello World")))
HTTP.register!(router, "/bye", HTTP.HandlerFunction(req -> HTTP.Messages.Response(200, "Bye")))
HTTP.register!(router, "*", HTTP.HandlerFunction(req -> HTTP.Messages.Response(404, "Not found")))
HTTP.serve(server, HOST, PORT) 

使用 HTTP 处理网页请求的工作流程需要四个实体——Server(服务器)、Router(路由器)、HandlerFunction(处理函数)和 Response(响应)。

从代码的最简单部分开始分析,在最后一行,我们通过调用 HTTP.serve 来启动服务器。serve 函数接受一个 server,一个类型为 Server 的对象,以及用于附加和监听请求的 HOST 信息(一个 IP 字符串)和 PORT(一个整数)作为参数。我们在文件顶部定义了 HOSTPORT 作为常量。HOST 的值使用非标准的 ip"" 字面量定义。我们在讨论 String 类型时学习了非标准字符串字面量。在这方面,ip"..." 语法类似于正则表达式 (r"...")、版本字符串 (v"...") 或 Pkg 命令 (pkg"...")。

实例化一个新的 Server 需要一个 Router 对象,我们将它命名为 routerRouter 的任务是注册一个映射列表(称为 路由),这些映射是在互联网上由我们的应用程序公开的链接(URI)和我们的 Julia 函数(称为 HandlerFunctions)之间。我们使用 register! 函数设置了路由,传递了 router 对象、URI 结构(如 //bye)和相应的 HandlerFunction 对象作为参数。

现在,如果你查看 HandlerFunction 的主体,你会看到根页面 / 将显示字符串 "Hello World"/bye URL 将显示字符串 "Bye";最后,所有其他由星号符号 * 表示的 URI 将返回一个 "Not found" 文本,并伴随正确的 404 Not Found 标头。

我相信你现在可以识别出箭头 -> 操作符,这暗示了使用 lambda 函数。每个 HandlerFunction 构造函数都接受一个匿名函数。这个函数负责处理请求并生成适当的 Response。作为其参数,它接受名为 reqRequest 对象,并预期返回一个 Response 实例。

在我们的示例代码中,我们使用了两种可用的 HTTP 状态码(200 表示 OK404 表示页面未找到),以及一些用于响应体的字符串(分别是简单的字符串 "Hello World""Bye""Not found")来构建了三个 Response 对象。

总结来说,当服务器收到请求时,它会将其委托给路由器,路由器将请求的 URI 与最合适的映射 URI 模式匹配,并调用相应的 HandlerFunction,将 Request 作为参数传入。处理函数返回一个 Response 对象,该对象由服务器发送回客户端。

让我们看看它的实际效果。您可以在编辑器中使用 Run 功能,或者您可以在终端中执行 $ julia hello.jl。或者,您也可以在本章的配套 IJulia 笔记本中运行代码:

图片

上一张截图显示了在 Juno 中运行的 hello.jl 文件。REPL 窗格显示了在接收和处理请求时来自网络服务器的调试信息。

服务器一准备好,您就会收到一条日志消息,说明服务器正在监听指定的套接字。此时,您可以在网络浏览器中导航到 http://localhost:9999。您将看到(或许)著名的 Hello World 消息,如下所示:

图片

恭喜——我们刚刚用 Julia 开发了我们的第一个网络应用!

当您导航到 http://localhost:9999/bye 时,猜猜会发生什么,没有加分。

最后,您可以通过尝试导航到 http://localhost:9999/bye 来确认任何其他请求都将导致 404 Not Found 页面。

图片

这里是 未找到 页面,正确地返回了 404 状态码。

开发游戏的网络用户界面

请启动您最喜欢的 Julia 编辑器并打开我们在上一章中使用的 sixdegrees/ 文件夹。它应该包含我们之前已经工作过的所有文件——six_degrees.jl,以及 ArticlesDatabaseGameplayWikipedia 模块。

如果您还没有跟上到这一点的代码,您可以下载本章的配套支持文件,这些文件可在以下网址找到:github.com/PacktPublishing/Julia-Programming-Projects/tree/master/Chapter05

为我们的网络应用添加一个新文件。由于这次代码会更复杂,并且应该与我们的其他模块集成,让我们在新的 WebApp.jl 文件中定义一个 WebApp 模块。然后,我们可以添加以下几行代码:

module WebApp 

using HTTP, Sockets 

const HOST = ip"0.0.0.0" 
const PORT = 8888 
const ROUTER = HTTP.Router() 
const SERVER = HTTP.Server(ROUTER) 

HTTP.serve(SERVER, HOST, PORT) 

end 

没有惊喜——与之前的例子类似,我们定义了 HOSTPORT 的常量,然后实例化一个 Router 和一个 Server 并开始监听请求。代码应该可以正常工作,但现在运行它还没有什么实际作用。我们需要定义和注册我们的路由,然后设置生成游戏页面的处理函数。

定义我们的路由

通过回顾我们在本章开头定义的高级规范,我们可以确定以下页面:

  • 着陆页面:我们网络应用的起始页面和主页,玩家可以在这里开始新游戏并选择难度。

  • 新游戏页面:根据难度设置启动新游戏。

  • 维基百科文章页面:这将显示链中链接对应的维基百科文章,并更新游戏的统计数据。在这里,我们还将检查当前文章是否是目标(结束)文章,即以胜利者的身份完成游戏。如果不是,我们将检查是否达到了文章的最大数量,如果是,则以失败者的身份结束游戏。

  • 返回页面:这将允许玩家在解决方案未找到时返回链。我们将显示相应的维基百科文章,同时正确更新游戏的统计数据。

  • 解决方案页面:如果玩家放弃,此页面将显示链中的最后一篇文章,以及到达该文章的路径。游戏以失败结束。

  • 任何其他页面都应该最终显示为未找到

考虑到路由处理程序将会相当复杂,最好我们不要在路由定义中直接定义它们。相反,我们将使用单独定义的函数。我们的路由定义将看起来像这样——请按照以下方式将它们添加到WebApp模块中:

HTTP.register!(ROUTER, "/", landingpage) # root page 
HTTP.register!(ROUTER, "/new/*", newgamepage) # /new/$difficulty_level -- new game 
HTTP.register!(ROUTER, "/*/wiki/*", articlepage) # /$session_id/wiki/$wikipedia_article_url -- article page 
HTTP.register!(ROUTER, "/*/back/*", backpage) # /$session_id/back/$number_of_steps -- go back the navigation history 
HTTP.register!(ROUTER, "/*/solution", solutionpage) # /$session_id/solution -- display the solution 
HTTP.register!(ROUTER, "*", notfoundpage) # everything else -- not found

你可能想知道为什么 URI 模式前面有额外的*。我们提到我们需要一种方法来识别在无状态的网络请求之间的运行游戏会话。articlepagebackpagesolutionpage函数都将需要一个现有的游戏会话。我们将把会话 ID 作为 URL 的第一部分传递。实际上,它们的路径将被解释为/$session_id/wiki/*/$session_id/back/*/$session_id/solution,其中$session_id变量代表唯一的游戏标识符。至于尾随的*,对于不同的路由有不同的含义——在new的情况下,它是游戏的难度级别;对于articlepage,它是实际的维基百科 URL,也是我们的文章标识符;对于backpage,它代表导航堆栈中的索引。类似于正则表达式,对于路由匹配也是如此,*将匹配任何内容。如果这听起来很复杂,不要担心——看到并运行代码会让事情变得清晰。

让我们为每个处理程序函数添加占位符定义——请在路由列表之前添加这些定义:

const landingpage = HTTP.HandlerFunction() do req 
end 
const newgamepage = HTTP.HandlerFunction() do req 
end 
const articlepage = HTTP.HandlerFunction() do req 
end 
const backpage = HTTP.HandlerFunction() do req  
end 
const solutionpage = HTTP.HandlerFunction() do req 
end 
const notfoundpage = HTTP.HandlerFunction() do req 
end 

准备着陆页面

立即,我们可以处理着陆页面处理程序。它需要做的只是显示一些静态内容,描述游戏规则,并提供以不同难度级别开始新游戏的方式。记住,游戏的难度决定了文章链的长度,我们在开始新游戏时需要这个信息。我们可以将其作为 URL 的一部分传递给新游戏页面,格式为/new/$difficulty_level。难度级别已经在Gameplay模块中定义,所以别忘了声明我们正在使用 Gameplay

考虑到这一点,我们将得到以下WebApp模块的代码。我们正在将所有内容组合在一起,同时也添加了landingpage HandlerFunction。这与第一个路由HTTP.register!(ROUTER, "/", landingpage)相关联。这意味着当我们通过浏览器访问/路由时,landingpage HandlerFunction将被执行,其输出将作为响应返回。在这种情况下,我们只是返回一些 HTML 代码。如果你不熟悉 HTML,以下是标记的作用——我们包含了 Twitter Bootstrap CSS 主题来使我们的页面更美观,我们显示了一些解释游戏规则的段落,并显示了三个用于开始新游戏的按钮——每个难度级别一个按钮。

这里是代码:

module WebApp 

using HTTP, Sockets 
using ..Gameplay 

# Configuration 
const HOST = ip"0.0.0.0" 
const PORT = 8888 
const ROUTER = HTTP.Router() 
const SERVER = HTTP.Server(ROUTER) 

# Routes handlers 
const landingpage = HTTP.HandlerFunction() do req 
  html = """ 
  <!DOCTYPE html> 
  <html> 
  <head> 
    <meta charset="utf-8" /> 
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous"> 
    <title>6 Degrees of Wikipedia</title> 
  </head> 

  <body> 
    <div class="jumbotron"> 
      <h1>Six degrees of Wikipedia</h1> 
      <p> 
        The goal of the game is to find the shortest path between two random Wikipedia articles.<br/> 
        Depending on the difficulty level you choose, the Wiki pages will be further apart and less related.<br/> 
        If you can't find the solution, you can always go back up the articles chain, but you need to find the solution within the maximum number of steps, otherwise you lose.<br/> 
        If you get stuck, you can always check the solution, but you'll lose.<br/> 
        Good luck and enjoy! 
      </p> 

      <hr class="my-4"> 

      <div> 
        <h4>New game</h4> 
          <a href="/new/$(Gameplay.DIFFICULTY_EASY)" class="btn btn-primary btn-lg">Easy ($(Gameplay.DIFFICULTY_EASY) links away)</a> | 
          <a href="/new/$(Gameplay.DIFFICULTY_MEDIUM)" class="btn btn-primary btn-lg">Medium ($(Gameplay.DIFFICULTY_MEDIUM) links away)</a> | 
          <a href="/new/$(Gameplay.DIFFICULTY_HARD)" class="btn btn-primary btn-lg">Hard ($(Gameplay.DIFFICULTY_HARD) links away)</a> 
        </div> 
    </div> 
  </body> 
  </html> 
  """ 

  HTTP.Messages.Response(200, html) 
end 

const newgamepage = HTTP.HandlerFunction() do req 
end 

const articlepage = HTTP.HandlerFunction() do req 
end 

const backpage = HTTP.HandlerFunction() do req 
end 

const solutionpage = HTTP.HandlerFunction() do req 
end 

const notfoundpage = HTTP.HandlerFunction() do req 
end 

# Routes definitions 
HTTP.register!(ROUTER, "/", landingpage) # root page 
HTTP.register!(ROUTER, "/new/*", newgamepage) # /new/$difficulty_level -- new game 
HTTP.register!(ROUTER, "/*/wiki/*", articlepage) # /$session_id/wiki/$wikipedia_article_url -- article page 
HTTP.register!(ROUTER, "/*/back/*", backpage) # /$session_id/back/$number_of_steps -- go back the navigation history 
HTTP.register!(ROUTER, "/*/solution", solutionpage) # /$session_id/solution -- display the solution 
HTTP.register!(ROUTER, "*", notfoundpage) # everything else -- not found 

# Start server 
HTTP.serve(SERVER, HOST, PORT) 

end 

让我们更新six_degrees.jl文件以启动我们的 Web 应用。请确保它现在如下所示:

using Pkg 
pkg"activate ." 

include("Database.jl") 
include("Wikipedia.jl") 
include("Gameplay.jl") 
include("WebApp.jl") 

using .Wikipedia, .Gameplay, .WebApp 

使用你喜欢的途径运行six_degrees.jl,无论是编辑器还是终端($ julia six_degrees.jl)。寻找消息Info: Listening on:...,这会通知我们 Web 服务器已启动。在你的浏览器中访问http://localhost:8888/,欣赏我们的着陆页!我相信你会注意到包含 Twitter Bootstrap CSS 文件的效果——只需在我们的代码中添加几个 CSS 类,就能产生很大的视觉冲击力!

上一张截图是我们游戏着陆页在本地主机8888端口上运行时的样子。

开始新游戏

太棒了!现在,让我们专注于开始新游戏的功能。在这里,我们需要实现以下步骤:

  1. 从 URL 中提取难度设置。

  2. 开始新游戏。这个游戏应该有一个 ID,即我们的session id。此外,它应该跟踪文章列表、进度、导航历史、总步数和难度。

  3. 渲染第一篇维基百科文章。

  4. 设置文章内导航。我们需要确保维基百科文章内的链接能够正确链接回我们的应用,而不是维基百科网站本身。

  5. 显示有关游戏会话的信息,例如目标(起始和结束文章)、所走步数等。

我们将在下一节中查看所有这些步骤。

从页面 URL 中提取难度设置

这是第一步。记住,在我们的HandlerFunction中,我们可以访问Request对象,req。所有的Request对象都暴露一个名为target的字段,它引用请求的 URL。target不包括协议或域名,所以它的形式将是/new/$difficulty_level。提取$difficulty_level值的一个快速方法是简单地用空字符串""替换 URI 的第一部分,从而有效地删除它。结果将用于一个函数newgamesession,以创建指定难度的游戏。用代码表示,它将看起来像这样:

game = parse(UInt8, (replace(req.target, "/new/"=>""))) |> newgamesession 

由于我们将难度级别表示为整数(文章数量),我们在使用它之前将字符串解析为整数(具体为 UInt8 类型)。

开始一个新的游戏会话

开始一个新的游戏会话是第二步。游戏会话管理器,应该包括前面的 newgamesession 函数,完全缺失,所以现在是时候添加它了。我们将游戏会话表示为相应类型的实例。让我们将 type 定义和操作它的方法打包到一个专用模块中。我们可以将模块命名为 GameSession,类型命名为 Game。请在 "sixdegrees/" 文件夹中创建 GameSession.jl 文件。

我们的 Game 类型需要一个自定义构造函数。我们将提供难度级别,构造函数将负责设置所有内部设置——它将使用之前创建的 Gameplay.newgame 函数获取正确数量的维基百科文章;它将创建一个唯一的游戏 ID(这将是我们会话 ID);并且它将使用默认值初始化其余字段。

第一次尝试可能看起来像这样:

module GameSession 

using ..Gameplay, ..Wikipedia, ..Wikipedia.Articles 
using Random 

mutable struct Game 
  id::String 
  articles::Vector{Article} 
  history::Vector{Article} 
  steps_taken::UInt8 
  difficulty::UInt8 

  Game(game_difficulty) = 
    new(randstring(), newgame(game_difficulty), Article[], 0, game_difficulty) 
end 

const GAMES = Dict{String,Game}() 

end

Random.randstring 函数创建一个随机字符串。这是我们游戏和会话的 ID。

我们还定义了一个 GAMES 字典,它将存储所有活动游戏,并允许我们通过其 id 字段查找它们。记住,我们的游戏是在网络上公开的,所以我们将有多个并行运行的游戏会话。

我们现在可以添加其余的函数。在模块的关闭 end 之前添加以下定义,如下所示:

export newgamesession, gamesession, destroygamesession 

function newgamesession(difficulty) 
  game = Game(difficulty) 
  GAMES[game.id] = game 
  game 
end 

function gamesession(id) 
  GAMES[id] 
end 

function destroygamesession(id) 
  delete!(GAMES, id) 
end
newgamesession function, which creates a new Game of the indicated difficulty and stores it into the GAMES dict data structure. There's also a getter function, gamesession, which retrieves a Game by id. Finally, we add a destructor function, which removes the corresponding Game from the GAMES dict, effectively making it unavailable on the frontend and leaving it up for garbage collection. All of these functions are exported.

值得注意的是,为了这个学习项目,将我们的游戏存储在内存中是可以的,但在实际生产中,随着玩家数量的增加,你可能会很快耗尽内存。对于生产使用,我们最好将每个 Game 持久化到数据库中,并在需要时检索它。

从链中渲染第一篇维基百科文章

这是第三步。回到我们的 WebApp 模块(在 WebApp.jl 中),让我们继续处理 newgamepage 处理器的逻辑。实现方式如下:

using ..GameSession, ..Wikipedia, ..Wikipedia.Articles 

const newgamepage = HTTP.HandlerFunction() do req 
  game = parse(UInt8, (replace(req.target, "/new/"=>""))) |> newgamesession 
  article = game.articles[1] 
  push!(game.history, article) 

  HTTP.Messages.Response(200, wikiarticle(article)) 
end

一旦我们创建了一个新的游戏,我们需要引用它的第一篇文章。我们将起始文章添加到游戏的历史记录中,然后使用以下 wikiarticle 函数将其渲染为 HTML:

function wikiarticle(article) 
  html = """ 
  <!DOCTYPE html> 
  <html> 
  <head> 
    <meta charset="utf-8" /> 
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous"> 
    <title>6 Degrees of Wikipedia</title> 
  </head> 

  <body> 
    <h1>$(article.title)</h1> 
    <div id="wiki-article"> 
      $(article.content) 
    </div> 
  </body> 
  </html> 
  """ 
end

我们简单地显示维基百科文章的标题作为主标题,然后是内容。

最后,别忘了通过将其添加到 "six_degrees.jl" 中将 GameSession 加载到我们的应用中。请注意,它需要在 WebApp 之前加载,以便 WebApp 可以使用。现在,完整的 "six_degrees.jl" 文件应该看起来像这样:

using Pkg pkg"activate ." include("Database.jl") include("Wikipedia.jl") include("Gameplay.jl") include("GameSession.jl") include("WebApp.jl") using .Wikipedia, .Gameplay, .GameSession, .WebApp 

如果你重新运行我们的代码并导航到 http://localhost:8888/new/2,你会看到我们的应用正在渲染一个随机的维基百科文章:

这是一个不错的开始,但也有一些问题。首先,我们在从维基百科获取内容时有点过于贪婪。它包含了完整的页面 HTML,其中包含我们实际上并不需要的东西,比如不可见的<head>部分和文章文本上方的所有太明显的维基百科内容(搜索表单、菜单等等)。这很容易解决——我们只需要通过使用更明确的 CSS 选择器来更具体地定义我们想要的内容。在浏览器的检查器中稍微玩一下,就可以发现所需的选择器是#bodyContent

带着这个知识,我们需要更新Wikipedia模块。请用以下函数替换现有的articleinfo函数:

function articleinfo(content) 
  dom = articledom(content) 
  (extractcontent(dom.root), extractlinks(dom.root), extracttitle(dom.root), extractimage(dom.root)) 
end 

我们现在将只提取所需 CSS 选择器的内容,而不是使用整个 HTML。

function extractcontent(elem) 
  matchFirst(Selector("#bodyContent"), elem) |> string 
end 

请在Wikipedia.jl文件中,在extractimage函数下添加extractcontent的定义。

通过重新访问http://localhost:8888/new/2页面,我们将看到我们的努力得到了一个看起来好得多的替代品:

图片

设置文章内导航

好吧,这并不难!但下一个问题更难。第四步完全是关于设置。我们确定我们需要捕获所有内部维基百科链接,这样当玩家点击链接时,他们会转到我们的应用而不是去原始的维基百科文章。这项工作的一半是由维基百科的内容本身完成的,因为它使用相对 URL。也就是说,它不是使用形式为https://en.wikipedia.org/wiki/Wikipedia:Six_degrees_of_Wikipedia的绝对 URL,而是使用相对形式/wiki/Wikipedia:Six_degrees_of_Wikipedia。这意味着当在浏览器中渲染时,这些链接将继承当前主机的域名(或基础 URL)。也就是说,当在http://localhost:8888/上渲染维基百科文章的内容时,它的相对 URL 将被解释为http://localhost:8888/wiki/Wikipedia:Six_degrees_of_Wikipedia。因此,它们将自动指向我们的网络应用。这很好,但拼图中还缺一块大拼图:我们说过,我们想要通过将会话 ID 作为 URL 的一部分来保持游戏的状态。因此,我们的 URL 应该是形式为http://localhost:8888/ABCDEF/wiki/Wikipedia:Six_degrees_of_Wikipedia,其中ABCDEF代表游戏(或会话)ID。最简单的解决方案是在渲染内容时将/wiki/替换为/ABCDEF/wiki/——当然,使用实际的游戏 ID 而不是ABCDEF

WebApp.wikiarticle函数的定义中,请查找以下内容:

<div id="wiki-article"> 
     $(article.content) 
</div> 

用以下内容替换它:

<div id="wiki-article"> 
    $(replace(article.content, "/wiki/"=>"/$(game.id)/wiki/")) 
</div> 

因为我们现在需要game对象,我们必须确保将其传递到函数中,所以它的声明应该变成以下这样:

function wikiarticle(game, article) 

这意味着我们还需要更新newgamepage路由处理程序,以正确调用更新的wikiarticle函数。WebApp.newgamepage函数的最后一行现在应该是这样的:

HTTP.Messages.Response(200, wikiarticle(game, article))  

如果你执行six_degrees.jl并将你的浏览器导航到http://localhost:8888/new/2,你应该会看到一个包含所有包含游戏 ID 的内部链接的维基百科文章的渲染效果:

图片

在前面的屏幕截图中,我们可以看到所有 URL 都以/x2wHk2XI开头——我们的游戏 ID。

显示游戏会话信息

对于我们规格说明的第五部分和最后一部分,我们需要显示游戏信息并提供一种方式来导航回之前的文章。我们将定义以下函数:

function objective(game) 
  """ 
  <h3> 
    Go from <i>$(game.articles[1].title)</i>  
    to <i>$(game.articles[end].title)</i> 
  </h3> 
  <h5> 
    Progress: $(size(game.history, 1) - 1)  
    out of maximum $(size(game.articles, 1) - 1) links  
    in $(game.steps_taken) steps 
  </h5> 
  <h6> 
    <a href="/$(game.id)/solution">Solution?</a> |  
    <a href="/">New game</a> 
  </h6>""" 
end 

objective函数会告知玩家起始和结束文章以及当前进度。它还提供了一个小的菜单,以便你可以查看解决方案或开始新游戏。

为了导航回上一页,我们需要生成游戏历史链接:

function history(game) 
  html = "<ol>" 
  iter = 0 
  for a in game.history 
    html *= """ 
    <li><a href="/$(game.id)/back/$(iter + 1)">$(a.title)</a></li> 
    """ 
    iter += 1 
  end 

  html * "</ol>" 
end

最后,我们需要一些额外的逻辑来检查游戏是否获胜或失败:

function puzzlesolved(game, article) 
  article.url == game.articles[end].url 
end

如果当前文章的 URL 与游戏中最后一条文章的 URL 相同,我们就有了胜者。

如果玩家用完了移动次数,游戏就输了:

function losinggame(game) 
  game.steps_taken >= Gameplay.MAX_NUMBER_OF_STEPS 
end

到目前为止,完整的代码应该看起来像这样:

module WebApp 

using HTTP, Sockets 
using ..Gameplay, ..GameSession, ..Wikipedia, ..Wikipedia.Articles 

# Configuration 
const HOST = ip"0.0.0.0" 
const PORT = 8888 
const ROUTER = HTTP.Router() 
const SERVER = HTTP.Server(ROUTER) 

# Functions 
function wikiarticle(game, article) 
  html = """ 
  <!DOCTYPE html> 
  <html> 
  $(head()) 

  <body> 
    $(objective(game)) 
    $(history(game)) 
    <hr/> 
    $( 
      if losinggame(game) 
        "<h1>You Lost :( </h1>" 
      else 
        puzzlesolved(game, article) ? "<h1>You Won!</h1>" : "" 
      end 
    ) 

    <h1>$(article.title)</h1> 
    <div id="wiki-article"> 
      $(replace(article.content, "/wiki/"=>"/$(game.id)/wiki/")) 
    </div> 
  </body> 
  </html> 
  """ 
end 

function history(game) 
  html = "<ol>" 
  iter = 0 
  for a in game.history 
    html *= """ 
    <li><a href="/$(game.id)/back/$(iter + 1)">$(a.title)</a></li> 
    """ 
    iter += 1 
  end 

  html * "</ol>" 
end 

function objective(game) 
  """ 
  <h3> 
    Go from <i>$(game.articles[1].title)</i>  
    to <i>$(game.articles[end].title)</i> 
  </h3> 
  <h5> 
    Progress: $(size(game.history, 1) - 1)  
    out of maximum $(size(game.articles, 1) - 1) links  
    in $(game.steps_taken) steps 
  </h5> 
  <h6> 
    <a href="/$(game.id)/solution">Solution?</a> |  
    <a href="/">New game</a> 
  </h6>""" 
end 

function head() 
  """ 
  <head> 
    <meta charset="utf-8" /> 
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous"> 
    <title>6 Degrees of Wikipedia</title> 
  </head> 
  """ 
end 

function puzzlesolved(game, article) 
  article.url == game.articles[end].url 
end 

function losinggame(game) 
  game.steps_taken >= Gameplay.MAX_NUMBER_OF_STEPS 
end 

# Routes handlers 
const landingpage = HTTP.HandlerFunction() do req 
  html = """ 
  <!DOCTYPE html> 
  <html> 
  $(head()) 

  <body> 
    <div class="jumbotron"> 
      <h1>Six degrees of Wikipedia</h1> 
      <p> 
        The goal of the game is to find the shortest path between two random Wikipedia articles.<br/> 
        Depending on the difficulty level you choose, the Wiki pages will be further apart and less related.<br/> 
        If you can't find the solution, you can always go back up the articles chain, but you need to find the solution within the maximum number of steps, otherwise you lose.<br/> 
        If you get stuck, you can always check the solution, but you'll lose.<br/> 
        Good luck and enjoy! 
      </p> 

      <hr class="my-4"> 

      <div> 
        <h4>New game</h4> 
          <a href="/new/$(Gameplay.DIFFICULTY_EASY)" class="btn btn-primary btn-lg">Easy ($(Gameplay.DIFFICULTY_EASY) links away)</a> | 
          <a href="/new/$(Gameplay.DIFFICULTY_MEDIUM)" class="btn btn-primary btn-lg">Medium ($(Gameplay.DIFFICULTY_MEDIUM) links away)</a> | 
          <a href="/new/$(Gameplay.DIFFICULTY_HARD)" class="btn btn-primary btn-lg">Hard ($(Gameplay.DIFFICULTY_HARD) links away)</a> 
        </div> 
    </div> 
  </body> 
  </html> 
  """ 

  HTTP.Messages.Response(200, html) 
end 

const newgamepage = HTTP.HandlerFunction() do req 
  game = parse(UInt8, (replace(req.target, "/new/"=>""))) |> newgamesession 
  article = game.articles[1] 
  push!(game.history, article) 

  HTTP.Messages.Response(200, wikiarticle(game, article)) 
end 

const articlepage = HTTP.HandlerFunction() do req 
end 

const backpage = HTTP.HandlerFunction() do req 
end 

const solutionpage = HTTP.HandlerFunction() do req 
end 

const notfoundpage = HTTP.HandlerFunction() do req 
end 

# Routes definitions 
HTTP.register!(ROUTER, "/", landingpage) # root page 
HTTP.register!(ROUTER, "/new/*", newgamepage) # /new/$difficulty_level -- new game 
HTTP.register!(ROUTER, "/*/wiki/*", articlepage) # /$session_id/wiki/$wikipedia_article_url -- article page 
HTTP.register!(ROUTER, "/*/back/*", backpage) # /$session_id/back/$number_of_steps -- go back the navigation history 
HTTP.register!(ROUTER, "/*/solution", solutionpage) # /$session_id/solution -- display the solution HTTP.register!(ROUTER, "*", notfoundpage) # everything else -- not found # Start server HTTP.serve(SERVER, HOST, PORT) 

end

请注意,我们还重构了页面的<head>部分,将其抽象到head函数中,该函数由landingpagewikiarticle共同使用。这样,我们保持了代码的 DRY 原则,避免了重复的<head> HTML 元素。

现在,让我们确保将Gameplay.MAX_NUMBER_OF_STEPS添加到Gameplay.jl中。在难度常量下面添加它:

const MAX_NUMBER_OF_STEPS = 10 

显示维基百科文章页面

玩家已经阅读了起始文章并点击了内容中的链接。我们需要添加渲染链接文章的逻辑。我们将必须获取文章(或者如果它已经被获取,则从数据库中读取),显示它,并更新游戏状态。

这里是代码:

const articlepage = HTTP.HandlerFunction() do req 
  uri_parts = parseuri(req.target) 
  game = gamesession(uri_parts[1]) 
  article_uri = "/wiki/$(uri_parts[end])" 
  existing_articles = Articles.find(article_uri) 
  article = isempty(existing_articles) ?  
    persistedarticle(fetchpage(article_uri)...) :  
    existing_articles[1] 
  push!(game.history, article) 
  game.steps_taken += 1 
  puzzlesolved(game, article) && destroygamesession(game.id) 
  HTTP.Messages.Response(200, wikiarticle(game, article)) 
end

我们首先解析Request URI 以提取通过 GET 发送的所有值。它是一个格式为/$session_id/wiki/$article_name的字符串,例如,/c701b1b0b1/wiki/Buenos_Aires。我们希望将其分解成各个部分。由于这是一个我们需要多次执行的操作,我们将这个功能抽象到parseuri函数中:

function parseuri(uri) 
  map(x -> String(x), split(uri, "/", keepempty = false)) 
end

在这里,我们使用 Julia 的split函数将 URI 字符串分解成一个由SubString组成的Array,对应于正斜杠/之间的各个部分。然后,我们将得到的SubStringArray转换成一个StringArray,它被返回并存储在uri_parts变量中。

继续定义articlepage处理程序,我们使用uri_parts数组的第一元素,它对应于会话 ID,通过调用gamesession(uri_parts[1])来检索我们的游戏对象。使用最后一个元素,我们生成维基百科文章 URL。然后,我们通过 URL 查找文章,要么从数据库中检索它,要么从网站上获取它。

一旦我们有了文章,我们就将其添加到游戏的历史记录中,并增加game.steps_taken计数器。然后,我们检查是否应该以胜利结束游戏:

这是胜利文章页面的截图。设计不是很好,但胜利的甜蜜滋味肯定是的!

最后,类似于新游戏页面,我们通过渲染文章和所有游戏信息来响应。

在文章链中向上导航

请记住,后退导航 URL 看起来像/c701b1b0b1/back/1,其中第一部分是会话 ID,最后一部分是历史堆栈中项目的索引。为了实现它,工作流程与articlepage类似——我们解析Request URI,通过会话 ID 检索游戏,并从游戏的历史堆栈中获取文章。由于我们在游戏历史中后退,所以当前文章索引之后的所有内容都应该从导航堆栈中删除。完成后,我们通过渲染相应的维基百科文章来响应。代码简短且易于阅读:

const backpage = HTTP.HandlerFunction() do req 
  uri_parts = parseuri(req.target) 
  game = gamesession(uri_parts[1]) 
  history_index = parse(UInt8, uri_parts[end]) 

  article = game.history[history_index] 
  game.history = game.history[1:history_index] 

  HTTP.Messages.Response(200, wikiarticle(game, article)) 
end 

显示解决方案

对于解决方案页面,我们只需要从Request URI 中获取会话 ID。然后,我们遵循相同的流程来获取当前的Game对象。一旦我们有了它,我们将文章列表复制到历史堆栈中,以使用现有的渲染逻辑显示游戏的解决方案。我们还把steps_taken计数器设置为最大值,因为游戏被认为是失败的。最后,我们显示最后一条文章:

const solutionpage = HTTP.HandlerFunction() do req 
  uri_parts = parseuri(req.target) 
  game = gamesession(uri_parts[1]) 
  game.history = game.articles 
  game.steps_taken = Gameplay.MAX_NUMBER_OF_STEPS 
  article = game.articles[end]
  HTTP.Messages.Response(200, wikiarticle(game, article)) 
end

解决方案页面如下所示,将游戏判定为失败:

处理任何其他请求

与我们的Hello World示例类似,我们将对任何其他请求以404 Not Found响应:

const notfoundpage = HTTP.HandlerFunction() do req 
  HTTP.Messages.Response(404, "Sorry, this can't be found") 
end 

总结

我在WebApp.jl文件中添加了一些更多的 UI 调整,以使事情变得更有趣。以下是重要部分——请从github.com/PacktPublishing/Julia-Programming-Projects/blob/master/Chapter05/sixdegrees/WebApp.jl下载完整文件:

module WebApp 

# code truncated #

function history(game) 
  html = """<ol class="list-group">""" 
  iter = 0 
  for a in game.history 
    html *= """ 
      <li class="list-group-item"> 
        <a href="/$(game.id)/back/$(iter + 1)">$(a.title)</a> 
      </li> 
    """ 
    iter += 1 
  end 

  html * "</ol>" 
end 

function objective(game) 
  """ 
  <div class="jumbotron"> 
    <h3>Go from 
      <span class="badge badge-info">$(game.articles[1].title)</span> 
      to 
      <span class="badge badge-info">$(game.articles[end].title)</span> 
    </h3> 
    <hr/> 
    <h5> 
      Progress: 
      <span class="badge badge-dark">$(size(game.history, 1) - 1)</span> 
      out of maximum 
      <span class="badge badge-dark">$(size(game.articles, 1) - 1)</span> 
      links in 
      <span class="badge badge-dark">$(game.steps_taken)</span> 
      steps 
    </h5> 
    $(history(game)) 
    <hr/> 
    <h6> 
      <a href="/$(game.id)/solution" class="btn btn-primary btn-lg">Solution?</a> | 
      <a href="/" class="btn btn-primary btn-lg">New game</a> 
    </h6> 
  </div> 
  """ 
end 

# code truncated #

end 

你会看到我稍微调整了布局,并添加了一些额外的样式来使我们的 UI 更漂亮。以下是我们的游戏及其更新的外观:

至于其他文件,如果您需要,它们可以在本章的 GitHub 仓库中下载,该仓库可通过以下链接访问:github.com/PacktPublishing/Julia-Programming-Projects/tree/master/Chapter05/sixdegrees

这就是我们运行一整场 维基百科六度分隔 所需要做的全部。现在,是时候享受它了!

摘要

Julia 专注于科学计算和数据科学。但得益于其作为通用编程语言的优秀品质、原生并行计算特性和性能,我们在网络开发领域对 Julia 的应用非常出色。

包含生态系统提供了访问一组强大的库,这些库专门用于网络编程。它们相对较低级,但仍然抽象掉了直接与网络栈工作的大部分复杂性。HTTP 包在可用性、性能和灵活性之间提供了良好的*衡。

我们能够用如此少的代码构建一个相当复杂(尽管规模较小)的 Web 应用,这证明了该语言的力量和表现力,以及第三方库的质量。我们在学习项目中做得很好——现在是时候稍微放松一下,以 Julia 风格享受一轮 维基百科六度分隔 了!

第六章:使用 Julia 实现推荐系统

在前面的章节中,我们深入探讨了使用 Julia 进行数据挖掘和网页开发。我希望你在发现一些有趣的文章的同时,也享受了几轮轻松的 维基百科六度分隔 游戏。作为游戏的一部分,随机浏览数百万篇维基百科文章是一种真正有趣的方式,可以偶然发现有趣的新内容。虽然我确信,有时你会注意到并非所有文章都同样出色——也许它们是简短的,或主观的,或写得不好,或者简单地与你无关。如果我们能够了解每个玩家的个人兴趣,我们就可以过滤掉某些维基百科文章,从而使每次游戏会话都变成一次美妙的发现之旅。

结果表明,我们并非唯一在努力解决这一问题的人——信息发现是一个价值数十亿美元的问题,无论它是文章、新闻、书籍、音乐、电影、酒店,还是任何可以在互联网上销售的产品或服务。作为消费者,我们面临着巨大的选择多样性,同时,我们用于审查它们的时间越来越少——我们的注意力跨度也越来越短。即时提供相关推荐是所有成功在线*台的关键特性,从亚马逊到 Booking.com,再到 Netflix、Spotify、Udemy。所有这些公司都投资于构建强大的推荐系统,实际上是与伴随的数据收集和推荐算法一起创造了新的商业模式。

在本章中,我们将学习推荐系统——这些是最常见且最成功的算法,被广泛应用于解决各种商业需求。我们将探讨以下主题:

  • 推荐系统是什么以及它们是如何被使用的

  • 基于内容的推荐系统与协同过滤推荐系统

  • 基于用户和基于物品的推荐系统

  • 使用 DataFrames 和统计函数进行更高级的数据分析

  • 如何使用基于内容和协同过滤算法推出我们自己的推荐系统

技术要求

Julia 的包生态系统正在持续发展中,并且每天都有新的包版本发布。大多数时候这是一个好消息,因为新版本带来了新功能和错误修复。然而,由于许多包仍在测试版(版本 0.x)中,任何新版本都可能引入破坏性更改。因此,书中展示的代码可能无法正常工作。为了确保您的代码将产生与书中描述相同的结果,建议使用相同的包版本。以下是本章中使用的外部包及其特定版本:

CSV@v0.4.3
DataFrames@v0.15.2
Distances@v0.7.4
IJulia@v1.14.1
Plots@v0.22.0
StatPlots@v0.8.2

为了安装特定版本的包,您需要运行:

pkg> add PackageName@vX.Y.Z 

例如:

pkg> add IJulia@v1.14.1

或者,您也可以通过下载章节中提供的 Project.toml 文件,并使用 pkg> 命令如下实例化来安装所有使用的包:

julia> download("https://raw.githubusercontent.com/PacktPublishing/Julia-Programming-Projects/master/Chapter06/Project.toml", "Project.toml")
pkg> activate . 
pkg> instantiate

理解推荐系统

在其最广泛的意义上,推荐系统RS)是一种用于为个人提供有用物品建议的技术。这些建议旨在帮助在各种决策过程中,通常与购买或消费某一类产品或服务相关。它们可能涉及购买书籍、听歌曲、看电影、在特定餐厅用餐、阅读新闻文章,或为你的下一次假期选择酒店。

自从历史开始以来,人们就一直在依赖推荐。一些推荐系统研究人员认为,最早的推荐可能是关于危险植物、动物或地点的口头传播信息。其他人则认为,推荐系统在语言出现之前就已经存在,通过观察人类食用植物或不明智地面对危险生物(这可以算作一种极端且可能暴力的隐性评分例子,我们将在下一段中看到)对其他人类的影响来发挥作用。

但我们不必追溯到人类历史的深处。在更*(且不那么危险)的时代,我们可以找到一些非常成功的推荐系统实例,例如图书管理员根据你的品味和兴趣推荐书籍,肉店为你展示适合周日食谱的肉类产品,你的朋友对最新大片的观点,你邻居关于街对面的幼儿园的故事,甚至你的医生推荐的治疗方案以缓解症状和消除疾病原因。其他推荐系统可能更为正式,但同样普遍且熟悉,例如酒店星级分类排名或全球顶级海滩上的蓝色旗帜。

在很长一段时间里,各个领域的专家扮演着推荐者的角色,他们结合自己的专业知识和对我们品味和兴趣的理解,巧妙地探询我们的细节。然而,随着互联网和在线*台(电子商务网站、在线电台、电影流媒体*台和社交网络)的兴起,通过向潜在的大规模消费者群体(现在称为用户)提供大量商品(产品)目录,已经取代了传统模式。由于 24 小时可用性、语言障碍和数量庞大等因素的考虑,个人推荐已不再是一个可行的选择(尽管在过去的几年里,音乐、书籍、奢侈品等领域的由人编辑的推荐有所回归——但这又是另一个话题)。

这种选择数量的增加使得找到合适的产品变得非常困难。在此阶段,基于软件的推荐系统登上了舞台。

Amazon.com 被认为是第一个大规模部署软件推荐系统的在线业务,并带来了非凡的商业效益。后来,Netflix 因授予一个百万美元奖金给提出比他们更好的推荐算法的团队而闻名。如今,自动推荐系统为所有主要*台提供动力,从 Spotify 的 Discover Weekly 播放列表到 Udemy 的推荐课程。

推荐系统的分类

不同的商业需求——从购买新笔记本电脑后推荐相关产品,到编制完美的驾驶播放列表,再到帮助您重新与久未联系的同学取得联系——导致了不同推荐算法的发展。推出推荐系统的一个关键部分是选择适合当前问题的正确方法,以充分利用可用的数据。我们将探讨最常见且最成功的算法。

了解非个性化、刻板印象化和个性化推荐

从技术和算法的角度来看,最简单的推荐类型是非个性化的。也就是说,它们没有针对特定用户偏好进行定制。这类推荐可能包括畅销产品、各种前十名歌曲、热门电影或一周内下载量最高的应用程序。

非个性化推荐在技术上不太具有挑战性,但效力也相对较低。在某些情况下,它们可以是良好的*似,尤其是在产品目录不是很大时(例如,好莱坞发行的电影并不多)。但对于像 Amazon 这样的电子商务零售商来说,在任意给定时间有数百万种产品可供选择,使用通用推荐正确的机会很小。

非个性化推荐的改进来自于将它们与分类策略相结合。通过刻板印象化,我们可以使推荐的项目更加相关,尤其是在我们能够识别出显著不同的用户人口统计特征时。一个很好的例子是应用商店推荐,它们按国家划分。例如,以下是一份推荐的新游戏列表。如果您是从美国访问应用商店的用户,这将是您看到的情况:

图片

这就是罗马尼亚用户在同一时间的情况:

图片

您可以很容易地注意到顶级选择差异很大。这既是由文化差异和偏好驱动的,也是由可用性(版权和发行)问题驱动的。

在本章中,我们不会关注非个性化推荐,因为实现它们相当直接。制作此类推荐所需的所有内容就是确定相关指标和表现最佳的项目,例如应用的下载数量、书籍的销量、歌曲或电影的播放量等等。然而,作为一个商业解决方案,非个性化推荐不应被忽视,因为当处理没有呈现任何相关个人偏好的用户时——通常是新用户——它们可能是有用的。

理解个性化推荐

从商业和技术两个角度来看,最有趣的推荐系统是那些考虑用户偏好的(或用户排名)的系统。

显式和隐式评分

当寻找个性化功能时,我们必须考虑用户自愿提供的显式数据,以及他们在应用程序或网站上(或我们真正跟踪用户行为的任何其他地方)的行为产生的相关信息(例如,随着智能汽车和自主结账等技术的引入,线上和物理领域的界限变得越来越模糊,仅举几个例子)。显式评分包括对产品或体验进行评分、给电影或购买打星、转发或点赞帖子等行为。另一方面,不返回搜索结果页面、分享一首歌曲或观看视频直到结束都是隐式正面评分的例子,而退货、取消订阅或未完成在线培训课程或电子书都是负面隐式排名的例子。

理解基于内容的推荐系统

最常见且最成功的推荐类型之一是基于内容的。核心思想是,如果我表达了对一组特定项目的偏好,我很可能对具有相同属性的其他项目也感兴趣。例如,我观看《海底总动员》(2003)的事实可以用作一个迹象,表明我对动画和喜剧类型的其他电影也感兴趣。

或者,观看一部原始的《星球大战》电影可以解释为我喜欢该系列的其他电影,或者哈里森·福特主演的电影,或者乔治·卢卡斯执导的电影,或者科幻电影。事实上,Netflix 就采用了这样的算法,只是在更细粒度的层面上。根据最*的一篇文章,Netflix 有一个大型团队,负责详细观看和标记电影——随后将电影特征与用户群体相匹配。用户本身也被仔细地分类到成千上万的类别中。

更高级的内容推荐系统还会考虑不同标签的相对权重。在之前提到的《海底总动员》(2003)的情况下,建议应该更少关于有鱼和鲨鱼的电影,而更多关于它是一部有趣、轻松的家庭电影的事实,因此希望推荐将更多地是《海底总动员 2:多莉去哪儿》(2016)而不是大白鲨

让我们看看我们如何使用基于内容的算法构建一个基本的电影推荐系统。为了使事情简单,我已经设置了一个包含 2016 年顶级 10 部电影及其类型的表格。你可以在本书的 GitHub 仓库中找到此文件,作为top_10_movies.tsv,网址为github.com/PacktPublishing/Julia-Programming-Projects/blob/master/Chapter06/top_10_movies.tsv

图片

在前面的屏幕截图中,你可以看到我们如何使用二进制系统来表示一部电影是否属于某个类型(通过1编码)或不是(通过0编码)。

我们可以通过使用readdlm函数,该函数位于DelimitedFiles模块中,轻松地将这样的表从 CSV/TSV 文件加载到 Julia 中。此模块是 Julia 默认安装的一部分,因此无需添加:

julia> using DelimitedFiles 
Julia> movies = readdlm("top_10_movies.tsv", '\t', skipstart=1) 
skipstart=1 tells Julia to skip the first line when reading the *Tab* separated top_10_movies.tsv file—otherwise, Julia would interpret the header row as a data row as well.

还有一种选择是让readdlm知道第一行是标题,通过传递header = true。然而,这将改变函数调用的返回类型为(data_cells, header_cells)的元组,这在交互式环境中不会被漂亮地打印。在这个探索阶段,我们更倾向于以表格形式表示数据。结果是包含我们的电影标题及其类型的表格数据结构:

 10×9 Array{Any,2}: 
 "Moonlight (2016)"                   0  0  0  1  0  0  0  0 
 "Zootopia (2016)"                    1  1  1  0  0  0  0  0 
 "Arrival (2016)"                     0  0  0  1  0  1  0  1 
 "Hell or High Water (2016)"          0  0  0  1  0  1  0  0 
 "La La Land (2016)"                  0  0  1  1  0  0  1  0 
 "The Jungle Book (2016)"             1  0  0  0  1  0  0  0 
 "Manchester by the Sea (2016)"       0  0  0  1  0  0  0  0 
 "Finding Dory (2016)"                0  1  0  0  0  0  0  0 
 "Captain America: Civil War (2016)"  1  0  0  0  0  0  0  1 
 "Moana (2016)"                       1  1  0  0  0  0  0  0 

让我们看看我们可以向观看上述电影《海底总动员》(2003)的用户推荐哪部电影。烂番茄将《海底总动员》(2003)归类为动画喜剧儿童类型。我们可以这样编码:

julia> nemo = ["Finding Nemo (2003)", 0, 1, 1, 0, 1, 0, 0, 0] 9-element Array{Any,1}: 
  "Finding Nemo (2003)" 
 0 
 1 
 1 
 0 
 1 
 0 
 0 
 0 

要根据类型进行电影推荐,我们只需找到最相似的电影,即与我们观看的上述电影《海底总动员》(2003)共享最多类型的电影。

存在着许多用于计算项目之间相似度(或相反,距离)的算法——在我们的情况下,因为我们只处理二进制值,汉明距离看起来是一个不错的选择。汉明距离是一个用来表示两个二进制字符串之间差异的数字。这个距离是通过比较两个二进制值并考虑对应位不同的位置数量来计算的。我们将依次比较每个位,并根据位是否不同或相同记录10。如果它们相同,我们记录一个0。对于不同的位,我们记录一个1。然后,我们将记录中的所有10相加,以获得汉明距离。

Distances包中有一个用于计算汉明距离的函数。这是一个第三方 Julia 包,它提供了访问多种用于评估向量之间距离的函数,包括欧几里得、贾卡德、汉明、余弦等。我们只需运行以下命令即可访问这个功能丰富的宝藏:

julia> using Pkg 
pkg> add Distances  
julia> using Distances 

然后,我们需要遍历我们的电影矩阵,并计算每部电影与《海底总动员》(2003)之间的汉明距离:

julia> distances = Dict{String,Int}() 
Dict{String,Int64} with 0 entries 

julia> for i in 1:size(movies, 1) 
            distances[movies[i,:][1]] = hamming(Int[movies[i,2:end]...], Int[nemo[2:end]...]) 
       end 
Finding Nemo (2003). To do this, we only extracted the genres (leaving off the name of the movie) and converted the list of values into an array of Int. Finally, we placed the result of the computation into the distances Dict we defined previously, which uses the name of the movie as the key, and the distance as the value.

这就是最终结果:

julia> distances 
Dict{String,Int64} with 10 entries: 
  "The Jungle Book (2016)"            => 3 
  "Hell or High Water (2016)"         => 5 
  "Arrival (2016)"                    => 6 
  "La La Land (2016)"                 => 4 
  "Moana (2016)"                      => 3 
  "Captain America: Civil War (2016)" => 5 
  "Moonlight (2016)"                  => 4 
  "Finding Dory (2016)"               => 2 
  "Zootopia (2016)"                   => 2 
  "Manchester by the Sea (2016)"      => 4

由于我们正在计算距离,最相似的电影是那些距离最短的电影。因此,根据我们的推荐系统,一个看过《海底总动员》(2003)的用户接下来应该观看《海底总动员 2:多莉去哪儿》(2016)或《疯狂动物城》(2016)(距离为2),看完之后,应该继续观看《森林书》(2016)和《莫阿娜》(2016)(两者距离均为3)。如果你还没有看过这些推荐的影片,我可以告诉你,这些建议相当合适。同样,最不推荐的影片是《降临》(2016),尽管它是一部优秀的科幻剧情片,但与可爱的尼莫和健忘的多莉没有任何共同之处。

从基于关联的推荐开始

尽管基于内容的推荐系统能够产生很好的结果,但它们确实存在局限性。首先,它们不能用来推荐新项目。仅基于我对《海底总动员》(2003)的初始排名,我可能会一直只得到动画电影的推荐,而永远没有机会听到关于任何我有时会喜欢的纪录片、汽车或烹饪节目。

此外,它最适合那些可以重复购买的商品类别,例如书籍、应用、歌曲或电影等。但如果你在亚马逊上购买了一个新的洗碗机,属于家居和厨房类别,那么得到关于同一组产品(如冰箱或洗衣机)的推荐就没有太多意义,因为很可能你不会同时更换所有昂贵的厨房电器。然而,我可能需要相应的接头、阀门、管道以及其他安装洗碗机所需的东西,以及推荐的洗涤剂和其他配件。由于电子商务*台也在销售所有这些产品,因此一起订购并同时收到它们是有益的,这样可以节省运输费用。

这些产品组合可以成为基于产品关联的 RS 的基础。这类推荐相当常见,通常在电子商务*台上以经常一起购买的形式呈现。对于实体店来说,这种数据分析——也称为市场篮子分析——用于将一起购买的产品放置在靠*的物理位置。例如,想想意大利面和酱料并排摆放,或者洗发水和护发素并排摆放。

在基于关联的推荐系统中,最流行的算法之一是Apriori算法。它用于识别在不同场景(如购物篮、网页浏览、不良药物反应等)中经常一起出现的项目。Apriori算法通过使用关联规则帮助我们通过数据挖掘来识别相关性。

空间限制使我们无法深入了解构建此类系统的细节,但如果你想要深入了解这个话题,有许多免费资源可以帮助你入门。我建议从《基于市场篮子的电影推荐》开始,rpubs.com/vitidN/203264,因为它构建的电影推荐器与我们非常相似。

了解协同过滤

协同过滤CF)是另一种非常成功且广泛使用的推荐算法。它基于这样一个观点:具有相似偏好的用户将有相似的兴趣。如果两位客户,我们可以称他们为安妮和鲍勃,对《海底奇缘》(2003)给出了好评,并且安妮也高度评价了《海底总动员》(2016),那么鲍勃也很可能喜欢《海底总动员》(2016)。当然,比较两个用户和两个产品可能看起来不多,但应用到代表用户和产品的非常大数据集中,推荐就变得高度相关。

如果你对于 CF(协同过滤)和内容过滤之间的区别感到困惑,因为两者都可以根据《海底总动员》(2016)推断出《海底奇缘》(2003),关键点在于 CF 不关心项目属性。实际上,在使用 CF 时,我们不需要电影类型信息,也不需要任何其他标签。算法并不关心项目的分类。它基本上表明,如果由于任何原因,某些项目被用户子集高度评价,那么其他被同一子集高度评价的项目对我们目标用户来说将是相关的,从而形成良好的推荐。

理解用户-项目 CF

这就是基本思想,随着大数据的到来,CF 技术变得相当强大。由于它被应用于不同的商业需求和用法场景,算法被改进以更好地解决它试图解决的问题。因此,出现了一些其他方法,原始方法因此被称为用户-项目 CF

它之所以得名,是因为它以用户数据(用户偏好、排名)作为输入,并输出项目数据(项目推荐)。它也被称为基于用户的 CF

你可以在以下图表中看到它的说明:

图片

上述图表显示,安妮喜欢ABE,而鲍勃喜欢ABCD

recommender算法确定,在AnnieBob之间有很高的相似度,因为他们都喜欢项目AB。接下来,它将假设Annie也会喜欢 Bob 偏好列表中她尚未发现的其它项目,反之亦然——Bob会喜欢 Annie 列表中他尚未发现的项目。因此,由于 Annie 也喜欢项目 E,我们可以向Bob推荐它,并且由于Bob非常喜欢CD,而 Annie 对这些一无所知,我们可以自信地建议她检查它们。

让我们再举一个非常简单的例子,同样来自电影推荐领域。继续使用我们之前在烂番茄网站上列出的 2016 年十大电影,这次,让我们忽略按类型分类,而是想象我们有用户评分数据:

前面的屏幕截图显示了一个包含电影标题和用户及其对应评分的表格。正如现实生活中发生的那样,并非所有用户都为所有电影评分——未评分的表示为空单元格。

你会在前面的屏幕截图中注意到,由于信仰的奇特转折,用户的姓名提供了他们偏好的电影类型的线索。Acton 非常喜欢动作电影,而 Annie 则热爱动画。Comey 的最爱是喜剧,而 Dean 则喜欢优秀的戏剧。Kit 的最高排名是儿童电影,Missie 喜欢悬疑电影,而音乐电影是 Musk 狂热观看的原因。最后,Sam 是一位科幻迷。

数据集在本章的文件中以top_10_movies_user_rankings.csv的名称提供。请从github.com/PacktPublishing/Julia-Programming-Projects/blob/master/Chapter06/top_10_movies_user_rankings.csv下载它,并将其放置在您可以从 Julia 的 REPL 轻松访问的硬盘上的某个位置。

我们可以使用之前提到的相同的readdlm Julia 函数将其加载到内存中:

movies = readdlm("/path/to/top_10_movies_user_rankings.csv", ';') 

此文件使用;字符作为列分隔符,因此我们需要将其传递给readdlm函数调用。记住,在 Julia 中,";"':'不同。前者是一个长度为 1 的String,而后者是一个Char

这是读取.csv文件的结果——一个包含电影在行上和人在列上的矩阵,每个人的评分对应于行和列的交叉点:

它是有效的,但数据看起来并不太好。在现实生活中,数据通常不会从所有用户那里都有评分。缺失值被导入为空字符串"",并且标题被解释为矩阵中的条目。Julia 的readdlm非常适合快速导入数据,但对于更高级的数据处理,我们可以从使用 Julia 强大的DataFrames包中受益良多。

DataFrames是一个第三方 Julia 包,它提供了一组丰富的函数来操作表格数据。你应该在我们的第一章《Julia 编程入门》中了解它——如果不是,请花几分钟时间回顾一下那部分内容。接下来的讨论将假设你对DataFrames有基本的了解,这样我们就可以现在专注于更高级的功能和用例。

如果由于某种原因,你不再有DataFrames包,pkg> add DataFrames就是我们需要做的全部。在此同时,让我们也安装CSV包——它是一个强大的实用库,用于处理分隔文本文件。我们可以一步添加这两个包:

pkg> add DataFrames CSV 

我们将使用CSV来加载逗号分隔的文件并生成一个DataFrame

julia> movies = CSV.read("top_10_movies_user_rankings.csv", delim = ';') 

结果的DataFrame应该看起来像这样:

我们得到了一个美观的表格数据结构,缺失的评分被正确地表示为missing数据。

我们可以使用describe函数来快速总结我们的数据:

julia> describe(movies) 

这个输出的结果如下:

多个列存在缺失值。一个缺失值表示在数据集中不存在的数据。它在Missings包(github.com/JuliaData/Missings.jl)中定义,并且是Missing类型的单例实例。如果你熟悉 SQL 中的NULL或 R 中的NA,那么在 Julia 中missing是相同的。

在处理现实生活中的数据集时,缺失值是有问题的,因为它们可能会影响计算的准确性。因此,涉及missing值的常见操作通常会传播missing。例如,1 + missingcos(missing)都会返回missing

我们可以使用ismissing函数来检查一个值是否缺失:

julia> movies[1,2] 
missing

julia> ismissing(movies[1, 2]) 
true 

在许多情况下,缺失值需要被跳过或替换为有效值。替换missing的适当值将取决于具体情况,由业务逻辑决定。在我们的案例中,对于缺失的评分,我们可以使用值0。按照惯例,我们可以同意有效评分的范围是从110,并且评分0对应于没有任何评分。

一种替换的方法是遍历除了电影标题之外的所有列,然后遍历每个单元格,如果相应的值是缺失的,就将其替换为0。以下是代码:

julia> for c in names(movies)[2:end] 
           movies[ismissing.(movies[c]), c] = 0 
       end 

我们已经完成了——我们的数据现在干净了,所有之前缺失的值都被零替换了:

如果您能将我们数据的这个干净版本保存为Tab分隔的文件,以供将来参考,以下代码将有所帮助:

julia> CSV.write("top_10_movies_user_rankings.tsv", movies, delim='\t') 

现在我们已经将评分加载到 Julia 中,下一步是计算不同用户之间的相似度。在计算基于内容的推荐时我们使用的汉明距离,对于数值数据来说不是一个好的选择。一个更好的替代方案是皮尔逊相关系数。这个系数也称为皮尔逊 r或双变量相关,是衡量两个变量之间线性相关程度的度量。它的值在+1−1之间。1的值表示完全正线性相关(两个值同时增加),而-1表示完全负线性相关(一个值减少而另一个值增加)。0的值表示没有线性相关。

这里有一些散点图的示例,展示了相关系数的不同可视化(由 Kiatdd—自行工作,CC BY-SA 3.0,commons.wikimedia.org/w/index.php?curid=37108966):

让我们看看如何根据他们提供的电影评分计算 Acton 和 Annie 之间的相似度。让我们简化问题,严格关注他们的数据,通过提取Movie title列以及ActonAnnie列:

julia> acton_and_annie = movies[:, 1:3] 

输出如下:

这将返回另一个DataFrame,称为acton_and_annie,它对应于movies DataFrame中的一到三列,代表 Acton 和 Annie 对每部电影的评分。

这很好,但我们只对两个用户都评分的电影感兴趣。如果您还记得我们在第一章中关于DataFrame的讨论——“Julia 编程入门”,我们可以通过传递布尔值来选择行(和列)——true表示选择,false表示跳过。我们可以结合点语法进行元素级操作,检查:Acton:Annie列中的值是否大于0。代码将如下所示:

julia> acton_and_annie_in_common = acton_and_annie[(acton_and_annie[:Acton] .> 0) .& (acton_and_annie[:Annie] .> 0), :] 
(acton_and_annie[:Acton] .> 0) .& (acton_and_annie[:Annie] .> 0) expression to check element-wise if the values in the Acton and Annie columns are greater than 0. Each comparison will return an array of true/false values—more exactly two 10-element BitArrays, as follows:
julia> acton_and_annie[:Acton] .> 0 
10-element BitArray{1}: 
 false 
  true 
  true 
  true 
  true 
  true 
 false 
  true 
  true 
  true 

julia> acton_and_annie[:Annie] .> 0 
10-element BitArray{1}: 
  true 
  true 
 false 
 false 
 false 
  true 
 false 
  true 
 false 
  true 

接下来,我们将位运算符&应用于结果数组,该运算符也是元素级的:

julia> (acton_and_annie[:Acton] .> 0) .& (acton_and_annie[:Annie] .> 0) 
10-element BitArray{1}: 
 false 
  true 
 false 
 false 
 false 
  true 
 false 
  true 
 false 
  true 
DataFrame that contains only the movies that have been rated by both Acton and Annie:

输出如下:

让我们绘制评分。Julia 提供了相当多的绘图选项。我们在 第一章,开始使用 Julia 编程 中看到了一些,我们将在 第九章,处理日期、时间和时间序列 中更详细地探讨绘图。现在,我们将使用名为 Plots 的库来快速可视化我们的数据。

Plots 是设计为一个高级接口,用于其他绘图库(在 Plots 语言中称为 backends),例如 GRPyPlot。它基本上将多个低级绘图包(后端)统一在一个公共 API 下。

像往常一样,先使用 pkg> add Plots 命令,然后继续使用 using Plots

我们现在可以生成可视化:

julia> plot(acton_and_annie_in_common[:,2], acton_and_annie_in_common[:,3], seriestype=:scatter, xticks=0:10, yticks=0:10, lims=(0,11), label="")
plot function, passing it Acton's and Annie's ratings. As options, we ask it to produce a scatter plot. We also want to make sure that the axes start at 0 and end at 11 (so that value 10 is fully visible), with ticks at each unit. We'll end up with the following plot:

图片

从外观上看,用户的电影偏好之间有很好的相关性。但我们还可以做得更好。

Julia 的生态系统提供了访问另一个强大的包,它结合了绘图和统计功能。它被称为 StatPlots,实际上是在 Plots 包之上工作的,为 Plots 提供了统计绘图配方。它还支持开箱即用的 DataFrame 可视化,因此它非常适合我们的需求。

让我们使用 pkg> add StatPlots 命令添加它,并将其引入作用域(using StatPlots)。现在我们可以使用 StatPlots 提供的 @df 宏来生成我们数据的散点图:

julia> @df acton_and_annie_in_common scatter([:Acton], [:Annie], smooth = true, line = :red, linewidth = 2, title = "Acton and Annie", legend = false, xlimits = (5, 11), ylimits = (5, 11)) 

上述代码将产生以下可视化效果:

图片

这个新的图表显示了电影之间的相关性,尽管存在异常值。

让我们计算 Acton 和 Annie 评分之间的皮尔逊相关系数:

julia> using Statistics 
julia> cor(acton_and_annie_in_common[:Acton], acton_and_annie_in_common[:Annie]) 

0.6324555320336759 

几乎任何超过 0.6 的值都表示良好的相似性,所以看起来我们正在找到一些东西。

现在,我们可以向 Annie 推荐一些 Acton 的最爱电影,她还没有看过,如下所示:

julia> annies_recommendations = acton_and_annie[(acton_and_annie[:Annie] .== 0) .&  (acton_and_annie[:Acton] .> 0), :]
acton_and_annie DataFrame, we only select the rows where Annie's score is 0 (she hasn't rated the movie) and Acton's is greater than 0 (he has rated the movie).

我们将得到一个包含四行的 DataFrame

图片

然而,这里有一个小问题。我们假设所有评分都表示强烈的偏好,但在这个情况下,Acton 的许多评分实际上更像是表示不喜欢。除了 Captain America: Civil War (2016) 之外,所有可能的推荐都有不良的评分。幸运的是,这很容易解决——我们只需要推荐评分至少为 7 的电影:

julia> annies_recommendations = acton_and_annie[(acton_and_annie[:Annie] .== 0) .&(acton_and_annie[:Acton] .>= 7 ), :] 

这就留下了我们唯一一部电影,Captain America: Civil War (2016)

图片

现在我们已经理解了基于用户的推荐系统的逻辑,让我们将这些步骤全部放在一起,创建一个简单的推荐脚本。

我们将在一个脚本中分析我们用户的评分矩阵,该脚本将利用所有可用数据为我们所有用户生成推荐。

这是一个可能的实现——请创建一个名为user_based_movie_recommendations.jl的文件,并包含以下代码。请确保top_10_movies_user_rankings.tsv文件在同一个文件夹中(或者更新代码中的路径以匹配您的位置)。以下是代码:

using CSV, DataFrames, Statistics

const minimum_similarity = 0.8
const movies = CSV.read("top_10_movies_user_rankings.tsv", delim = '\t')

function user_similarity(target_user)
    similarity = Dict{Symbol,Float64}()
    for user in names(movies[:, 2:end])
        user == target_user && continue
        ratings = movies[:, [user, target_user]]
        common_movies = ratings[(ratings[user] .> 0) .& (ratings[target_user] .> 0), :]

        correlation = try
            cor(common_movies[user], common_movies[target_user])
        catch
            0.0
        end

        similarity[user] = correlation
    end

    similarity
end

function recommendations(target_user)
    recommended = Dict{String,Float64}()
    for (user,similarity) in user_similarity(target_user)
        similarity > minimum_similarity || continue
        ratings = movies[:, [Symbol("Movie title"), user, target_user]]
        recommended_movies = ratings[(ratings[user] .>= 7) .& (ratings[target_user] .== 0), :]

        for movie in eachrow(recommended_movies)
            recommended[movie[Symbol("Movie title")]] = movie[user] * similarity
        end
    end

    recommended
end

for user in names(movies)[2:end]
    println("Recommendations for $user: $(recommendations(user))")
end
user_similarity and recommendations. They both take, as their single argument, a user's name in the form of a Symbol. This argument matches the column name in our movies DataFrame.

user_similarity函数计算我们的目标用户(作为函数参数传入的用户)与其他所有用户的相似度,并返回一个形式的字典:

Dict(
    :Comey => 1.0,
    :Dean => 0.907841,
    :Missie => NaN,
    :Kit => 0.774597,
    :Musk => 0.797512,
    :Sam => 0.0,
    :Acton => 0.632456
)

dict表示安妮与其他所有用户的相似度。

我们在推荐函数中使用相似度来选择相关的用户,并根据他们喜欢的电影(这些电影尚未被我们的目标用户评分)进行推荐。

我还增加了一个小变化,使推荐更加相关——一个权重因子。这是通过将用户的评分与用户的相似度相乘来计算的。比如说,如果科米给一部电影打 8 分,并且与米西 100%相似(相关系数等于 1),那么推荐的权重也将是8(8 * 1)。但如果科米只与马斯克 50%相似(0.5 的相关系数),那么推荐的权重(对应于估计的评分)将只是4(8 * 0.5)

在文件末尾,我们通过遍历所有用户的数组来引导整个过程,并为每个用户生成和打印电影推荐。

运行此代码将输出电影推荐,以及每个用户的权重:

Recommendations for Acton: Dict("Moonlight (2016)"=>9.0)
Recommendations for Annie: Dict("La La Land (2016)"=>8.0)
Recommendations for Comey: Dict("The Jungle Book (2016)"=>7.0,"Moana (2016)"=>7.0,"Moonlight (2016)"=>9.0)
Recommendations for Dean: Dict("Moana (2016)"=>10.0,"Zootopia (2016)"=>10.0)
Recommendations for Kit: Dict("Hell or High Water (2016)"=>10.0,"Arrival (2016)"=>10.0,"La La Land (2016)"=>9.0,"Moonlight (2016)"=>10.0,"Manchester by the Sea (2016)"=>8.0)
Recommendations for Missie: Dict("The Jungle Book (2016)"=>8.0,
"Moana (2016)"=>8.0, "La La Land (2016)"=>8.0,"Captain America: Civil War (2016)"=>10.0,"Finding Dory (2016)"=>7.0,"Zootopia (2016)"=>9.0)
Recommendations for Musk: Dict{String,Float64}()
Recommendations for Sam: Dict("Hell or High Water (2016)"=>10.0,
"La La Land (2016)"=>9.0,"Moonlight (2016)"=>10.0,"Zootopia (2016)"=>7.0,"Manchester by the Sea (2016)"=>8.0)

考虑到这是一个玩具示例,数据看起来相当不错。一个生产质量的推荐系统应该基于数百万这样的评分。

然而,如果你仔细观察,可能会注意到有些地方不太对——Kit 的推荐。基特喜欢儿童电影——轻松的动画喜剧。我们的系统推荐给他很多,权重很大,很多悲剧!这是怎么回事?如果我们查看基特的相似度数据,我们会看到他与迪恩非常相关,而迪恩喜欢悲剧。这听起来可能有些奇怪,但如果检查数据,这实际上是正确的:

julia> movies[:, [Symbol("Movie title"), :Dean, :Kit]] 

输出如下:

图片

注意他们俩都看过的电影只有《森林书》(2016)和《海底总动员》(2016),以及评分是如何相关的,因为两者都对《海底总动员》(2016)给出了更高的评分。因此,迪恩和基特之间存在强烈的正相关。但我们的算法没有考虑到,即使迪恩比《森林书》(2016)更喜欢《海底总动员》(2016),他实际上对这两部电影都不太喜欢,正如他给出的低评分(分别为 4 和 2)所示。

解决方案相当简单,尽管如此——我们只需要移除那些没有表明强烈正面偏好的评分。再次强调,我们可以使用等于或大于7的评分来计算喜欢。因此,在user_similarity函数中,请查找以下行:

common_movies = ratings[(ratings[user] .> 0) .& (ratings[target_user] .> 0), :]

ratings[user] .> 0替换为ratings[user] .> 7,这样整个行现在如下所示:

common_movies = ratings[Array(ratings[user] .> 7) .& Array(ratings[target_user] .> 0), :]

这所做的现在是基于收藏夹来计算相似度。因此,Kit不再与Dean相似(相关系数为0)。

我们推荐更加有针对性的另一个后果是,我们不再为所有用户提供推荐——但这同样是由于我们正在处理一个非常小的示例数据集。以下是最终的推荐:

Recommendations for Acton: Dict("Moonlight (2016)"=>9.0) 
Recommendations for Annie: Dict{String,Float64}() 
Recommendations for Comey: Dict( 
"Moana (2016)"=>9.0, 
"Moonlight (2016)"=>9.0) 
Recommendations for Dean: Dict( 
"Moana (2016)"=>8.0, 
"Zootopia (2016)"=>9.0) 
Recommendations for Kit: Dict{String,Float64}() 
Recommendations for Missie: Dict{String,Float64}() 
Recommendations for Musk: Dict{String,Float64}() 
Recommendations for Sam: Dict{String,Float64}() 

我们只为 Acton、Comey 和 Dean 提供建议,但现在它们更加准确。

物品-物品协同过滤

基于用户的协同过滤工作得相当好,并且在野外生产中被广泛使用,但它确实有几个相当大的缺点。首先,很难从用户那里获取足够多的偏好信息,导致许多用户没有相关推荐的坚实基础。其次,随着*台和底层业务的增长,用户的数量将比物品的数量增长得快得多。例如,Netflix 为了将讨论保持在熟悉的电影领域,通过扩展到新国家,其用户基础大幅增长,而电影的产量在每年基本上保持不变。最后,用户的数据确实变化很大,因此评分矩阵需要经常更新,这是一个资源密集和时间消耗的过程。

这些问题在大约 10 年前的亚马逊变得非常明显。他们意识到,由于产品的数量增长速度远低于用户的数量,他们可以计算项目相似度,而不是用户相似度,并基于相关项目列表进行推荐。

下面的图表应该有助于你理解基于物品(或物品-物品)和基于用户(或用户-物品)协同过滤之间的区别:

图片

上述图表显示,安妮购买了ABE鲍勃购买了ABD,而查理购买了AC安妮鲍勃的购买行为将表明AB之间存在相关性,由于查理已经购买了A但没有购买B,我们可以向查理推荐看看B

从实现的角度来看,它与用户-项目协同过滤有相似之处,但它更为复杂,因为它包括一个额外的分析层。让我们用我们的假想电影排名来试试。让我们创建一个名为item_based_recommendations.jl的新文件来存放我们的代码。

这里是完整的实现:

using CSV, DataFrames, DelimitedFiles, Statistics

const minimum_similarity = 0.8

function setup_data()
    movies = readdlm("top_10_movies_user_rankings.tsv", '\t')
    movies = permutedims(movies, (2,1))
    movies = convert(DataFrame, movies)

    names = convert(Array, movies[1, :])[1,:]
    names!(movies, [Symbol(name) for name in names])
    deleterows!(movies, 1)
    rename!(movies, [Symbol("Movie title") => :User])
end

function movie_similarity(target_movie)
    similarity = Dict{Symbol,Float64}()
    for movie in names(movies[:, 2:end])
        movie == target_movie && continue
        ratings = movies[:, [movie, target_movie]]
        common_users = ratings[(ratings[movie] .>= 0) .& (ratings[target_movie] .> 0), :]

        correlation = try
            cor(common_users[movie], common_users[target_movie])
        catch
            0.0
        end

        similarity[movie] = correlation
    end

    # println("The movie $target_movie is similar to $similarity")
    similarity
end

function recommendations(target_movie)
    recommended = Dict{String,Vector{Tuple{String,Float64}}}()
    # @show target_movie
    # @show movie_similarity(target_movie)

    for (movie, similarity) in movie_similarity(target_movie)
        movie == target_movie && continue
        similarity > minimum_similarity || continue
        # println("Checking to which users we can recommend $movie")
        recommended["$movie"] = Vector{Tuple{String,Float64}}()

        for user_row in eachrow(movies)
            if user_row[target_movie] >= 5
                # println("$(user_row[:User]) has watched $target_movie so we can recommend similar movies")
                if user_row[movie] == 0
                    # println("$(user_row[:User]) has not watched $movie so we can recommend it")
                    # println("Recommending $(user_row[:User]) the movie $movie")
                    push!(recommended["$movie"], (user_row[:User], user_row[target_movie] * similarity))
                end
            end
        end
    end

    recommended
end

const movies = setup_data()
println("Recommendations for users that watched Finding Dory (2016): $(recommendations(Symbol("Finding Dory (2016)")))")

为了使代码更简单,我们只为单部电影生成推荐——但将其扩展到为列表中的每部电影生成推荐相对简单(你可以尝试作为练习来做)。我们只会向看过《海底总动员》(2016)的用户推荐类似的电影。

让我们将其拆开来看看脚本是如何工作的。

正如你所见,我添加了一些println@show调用,它们输出额外的调试信息——它们被注释掉了,但当你运行文件时,请随意取消注释它们,以帮助你更好地理解每个部分的作用以及代码的工作流程。

现在设置数据矩阵更困难了。我们需要转置我们的初始数据集,即旋转它。setup_data函数专门用于这项任务——加载数据文件,转置矩阵,并将数据设置到DataFrame中。这只是一个几行代码的合适提取、转换、加载ETL)过程,这相当酷!让我们仔细看看——这是一个相当常见的一天到晚的数据科学任务。

在函数的第一行,我们将数据加载到一个 Julia 矩阵中。readdlm函数没有DataFrames强大,所以它没有标题的知识,将所有内容都吞进一个Array

julia> movies = readdlm("top_10_movies_user_rankings.tsv", '\t') 

我们最终会得到以下矩阵:

正如我们所见,标题与实际数据混合在一起。

现在,我们需要转置矩阵。不幸的是,在 Julia 中,转置并不总是对所有类型的矩阵工作得很好,推荐的方法是通过permutedims来完成:

julia> movies = permutedims(movies, (2,1)) 

输出如下:

我们越来越接*了!

接下来,我们将其转换为DataFrame

julia> movies = convert(DataFrame, movies) 

输出如下:

如果你亲自运行前面的代码,你可能会注意到 REPL 会省略一些DataFrame列,因为输出太宽了。为了使 Julia 显示所有列,就像在这个片段中一样,你可以使用showall函数,例如showall(movies)

看起来不错,但我们需要给列赋予合适的名称,使用现在第一行上的数据。让我们将所有列名提取到一个Vector中:

julia> movie_names = convert(Array, movies[1, :])[1,:] 
11-element Array{Any,1}: 
 "Movie title" 
 "Moonlight (2016)" 
 "Zootopia (2016)" 
 "Arrival (2016)" 
 "Hell or High Water (2016)" 
 "La La Land (2016)" 
 "The Jungle Book (2016)" 
 "Manchester by the Sea (2016)" 
 "Finding Dory (2016)" 
 "Captain America: Civil War (2016)" 
 "Moana (2016)" 

现在,我们可以用它来命名列:

julia> names!(movies, [Symbol(name) for name in movie_names]) 

输出如下:

我们的DataFrame看起来已经好多了。剩下要做的只是删除带有标题的额外行,并将Movie title标题更改为User

julia> deleterows!(movies, 1) julia> rename!(movies, Symbol("Movie title") => :User) 

输出如下:

完成了——我们的 ETL 过程已经完成!

我们通过调用recommendations函数开始我们的推荐器,传入电影名称Finding Dory (2016)作为一个Symbol。这个函数首先调用movie_similarity函数,该函数根据用户的评分计算与Finding Dory (2016)相似的其他电影。对于我们的目标电影,我们将得到以下结果:

Dict( 
Symbol("La La Land (2016)")=>-0.927374, 
Symbol("Captain America: Civil War (2016)")=>-0.584176, 
Symbol("The Jungle Book (2016)")=>0.877386, 
Symbol("Manchester by the Sea (2016)")=>-0.785933, 
Symbol("Arrival (2016)")=>-0.927809, 
Symbol("Zootopia (2016)")=>0.826331, 
Symbol("Moonlight (2016)")=>-0.589269, 
Symbol("Hell or High Water (2016)")=>-0.840462, 
Symbol("Moana (2016)")=>0.933598
) 

我们可以看到,与La La Land (2016)几乎有完美的负相关性(所以喜欢La La Land (2016)的用户不喜欢Finding Dory (2016))。与The Jungle Book (2016)Zootopia (2016)Moana (2016)也有非常强的正相关性,这是有道理的,因为它们都是动画片。

这里逻辑变得稍微复杂一些。现在,我们有一个与《海底总动员》(2016)相似的电影的列表。为了做出推荐,我们想要查看所有看过《海底总动员》(2016)并且给出了足够好的评分的用户,并建议他们尚未观看的相似电影(评分为零的电影)。这次,我们将使用最低评分为 5 而不是之前的 7,因为鉴于我们的数据集非常有限,7 会过于严格,并且不会产生任何推荐。我们将计算建议的权重,即用户对《海底总动员》(2016)的评分与《海底总动员》(2016)和推荐电影之间的相关系数的乘积。这说得通吗?让我们看看实际效果吧!

如果我们运行脚本,我们会得到以下输出:

Recommendations for users that watched Finding Dory (2016): 
Dict( 
    "The Jungle Book (2016)"=> Tuple{String,Float64}[("Comey", 4.38693)], 
    "Moana (2016)"=> Tuple{String,Float64}[("Comey", 4.66799)], 
    "Zootopia (2016)"=> Tuple{String,Float64}[]
)

在我们的小数据集中,唯一可能对观看与《海底总动员》(2016)相似的电影感兴趣的(有点)用户是Comey——但推荐不会很好。算法估计《森林书》(2016)的权重(因此,评分)为 4.38693,而《莫阿娜》(2016)的权重为 4.66799。

摘要

这标志着我们探索推荐系统之旅的第一部分的结束。它们是当今在线商业模式中极其重要的组成部分,其有用性随着我们连接的软件和硬件生成数据的指数级增长而不断增长。推荐系统是解决信息过载问题——或者说,信息过滤问题的非常有效的解决方案。推荐器为每个用户提供适当的过滤级别,再次将信息转化为客户赋能的向量。

虽然理解各种推荐系统的工作原理对于选择适合你在数据科学家工作中解决的问题类型的算法至关重要,但手动实现生产级别的系统并不是大多数人会做的事情。就像软件开发领域的几乎所有事情一样,当有现成的稳定、强大和成熟的库可用时,最好使用它们。

在下一章中,我们将学习如何使用现有的 Julia 库构建一个更强大的推荐系统。我们将为约会网站生成推荐,利用公开可用的匿名约会数据。在这个过程中,我们将了解另一种类型的推荐系统,称为基于模型(顺便提一下,本章讨论的所有算法都是基于内存的,但不用担心——我马上会解释一切)。

第七章:推荐系统中的机器学习

我希望你现在对我们所构建的推荐系统所提供的惊人可能性感到兴奋。我们所学的技术将为你提供大量的数据驯服能力和实际应用能力,你可以在你的项目中立即应用。

然而,推荐系统不仅仅是这样。由于*年来在大型应用中的广泛应用,作为解决在线*台上大量提供所引起的信息过载的有效解决方案,推荐系统受到了很多关注,新算法的开发速度也在加快。事实上,我们在上一章研究的所有算法都属于一个单一类别,称为基于记忆推荐系统。除此之外,还有一个非常重要的推荐系统类别,被称为基于模型的。

在本章中,我们将学习这些内容。我们将讨论以下主题:

  • 基于记忆与基于模型的推荐系统比较

  • 为基于模型的推荐系统进行数据处理的训练

  • 构建基于模型的推荐系统

  • 混合推荐系统

技术要求

Julia 包生态系统正在持续发展中,并且每天都有新的包版本发布。大多数时候这是一个好消息,因为新版本带来了新功能和错误修复。然而,由于许多包仍在测试版(版本 0.x)中,任何新版本都可能引入破坏性更改。因此,书中展示的代码可能无法正常工作。为了确保你的代码将产生与书中描述相同的结果,建议使用相同的包版本。以下是本章使用的外部包及其特定版本:

CSV@v.0.4.3
DataFrames@v0.15.2
Gadfly@v1.0.1
IJulia@v1.14.1
Recommendation@v0.1.0+

为了安装特定版本的包,你需要运行:

pkg> add PackageName@vX.Y.Z 

例如:

pkg> add IJulia@v1.14.1

或者,你也可以通过下载本章提供的Project.toml文件并使用pkg>实例化来安装所有使用的包:

julia> download("https://raw.githubusercontent.com/PacktPublishing/Julia-Projects/master/Chapter07/Project.toml", "Project.toml")
pkg> activate . 
pkg> instantiate

比较基于记忆与基于模型的推荐系统

理解基于记忆和基于模型的推荐系统的优缺点非常重要,这样我们才能根据可用的数据和业务需求做出正确的选择。正如我们在上一章所看到的,我们可以根据它们使用的数据和采用的算法来对推荐系统进行分类。

首先,我们可以谈谈非个性化与个性化推荐系统。非个性化推荐系统不考虑用户偏好,但这并不意味着它们不那么有用。当相关数据缺失时,例如,对于新加入系统的用户或未登录的用户,它们可以成功应用。这类推荐可能包括苹果应用商店每周最佳应用、Netflix 的热门电影、Spotify 的每日歌曲、纽约时报的畅销书、公告牌前十名等。

接下来是个性化推荐系统,这些系统可以进一步分为基于内容和协同系统。基于内容的系统通过匹配项目规格来做出推荐。这个类别的著名例子是潘多拉及其音乐基因组项目。潘多拉背后的音乐基因组项目是对音乐进行的最为全面的分析。他们与训练有素的音乐学家合作,跨越所有流派和年代聆听音乐,研究并收集每首歌曲的音乐细节——总共 450 个音乐属性。潘多拉通过选择其目录中与用户之前喜欢的曲目特征紧密匹配的其他歌曲来进行推荐。(特征是数据科学语言中对属性、特性或标签的称呼。)

关于协同过滤,其背后的理念是我们可以找到一个正确反映用户喜好的指标,然后结合其他用户的偏好数据集来利用它。这个基本的假设是,如果我们有一个用户群体,他们喜欢许多相同的事物,我们可以向其中的一位推荐一些来自另一位用户列表中的项目,这些项目尚未被目标用户发现。任何不在目标用户列表中的选项都可以轻易地作为推荐提供,因为相似的偏好会导致其他相似的选择。

这种特定的协同过滤类型被称为基于用户的协同过滤,因为算法的主要焦点是目标用户与其他用户之间的相似性。

协同算法的另一种变体是基于项目的过滤。这种过滤与基于用户的过滤之间的主要区别在于,重点是相似的项目。哪种方法最好取决于具体的用例——当产品目录相对较小且变化不如用户及其偏好频繁时,基于项目的推荐更有效率。

最后,被普遍接受的分类方法将推荐系统分为基于内存和基于模型。基于内存指的是系统需要将整个数据集加载到工作内存(RAM)中。算法依赖于在内存之间映射和回映射来计算两个用户或项目之间的相似性,并通过取所有评分的加权*均来为用户生成预测。可以使用几种计算相关性的方法,例如皮尔逊相关系数。这种方法有一些优势,比如实现的简单性、新数据的容易引入,或者结果可以轻易解释。但是,不出所料,它也伴随着显著的性能劣势,当数据稀疏且数据集很大时,会引发问题。

由于基于记忆的推荐系统的局限性,需要替代解决方案,这主要是由在线业务的持续增长及其背后的数据驱动的。这些解决方案的特点是用户数量庞大,产品种类不断增加。最著名的例子是 Netflix 的一百万美元竞赛——2006 年,Netflix 向能够至少提高其现有推荐算法(称为Cinematch)10%的个人或团队提供一百万美元的奖金。这项壮举历时三年才完成,最终是由最初的一些竞争对手组成的联合团队完成的,他们最终决定联手争夺奖金。

了解基于模型的方法

这种推荐系统创新方法被称为基于模型,它广泛使用了矩阵分解技术。在这种方法中,使用不同的机器学习算法开发模型来预测用户的评分。从某种意义上说,基于模型的方法可以被视为一种补充技术,以改进基于记忆的推荐。它们通过猜测用户将喜欢新项目的程度来解决矩阵稀疏问题。机器学习算法用于训练特定用户的现有评分向量,然后构建一个可以预测用户尚未尝试的项目评分的模型。流行的基于模型技术包括贝叶斯网络、奇异值分解(SVD)和概率潜在语义分析(PLSA)或概率潜在语义索引(PLSI)。

建立模型有许多流行的方法:

  • 概率:推荐被框架化为预测评分具有特定值的概率问题。贝叶斯网络通常与这种实现一起使用。

  • 增强型基于记忆:这种方法使用一个模型来表示用户或项目之间的相似性,然后预测评分。Netflix 大奖的 ALS-WR 算法代表了这种类型的实现。

  • 线性代数:最后,可以通过对用户和评分的矩阵执行线性代数运算来做出推荐。常用的算法是奇异值分解(SVD)。

在接下来的章节中,我们将实现一个基于模型的推荐器。我们将使用第三方 Julia 包,并围绕它编写业务逻辑。

理解我们的数据

要从我们的机器学习ML)模型中获得结论性的结果,我们需要数据——而且需要大量的数据。网上有大量的开源数据集。例如,Kaggle 提供了一个大量高质量和匿名数据集的集合,可用于培训和实验,并可在www.kaggle.com/datasets下载。另一个著名的数据仓库由 FiveThirtyEight 提供,在github.com/fivethirtyeight/data。Buzzfeed 也在github.com/BuzzFeedNews公开了大量数据。

对于我们的项目,我们将创建一个书推荐系统。我们将使用Book-Crossing Dataset,该数据集可在www2.informatik.uni-freiburg.de/~cziegler/BX/下载。这些数据是在 2004 年 8 月和 9 月期间,在 Book-Crossing 社区(www.bookcrossing.com/)的许可下收集的。它包括超过 110 万条书籍评分,涉及 27 万多种书籍,来自 27.8 万名用户。用户数据已匿名化,但仍包括人口统计信息(位置和年龄,如有)。我们将使用这些数据来训练我们的推荐系统,然后要求它为我们用户推荐有趣的新书。

初步查看数据

数据集由三个表组成——一个用于用户,一个用于书籍,一个用于评分。BX-Users 表包含用户数据。User-ID 是一个顺序整数值,因为原始用户 ID 已被匿名化。《Location》和《Age》列包含相应的人口统计信息。并非所有用户都有这些信息,在这些情况下,我们会遇到NULL值(作为NULL字符串)。

BX-Books 表存储有关书籍的信息。对于唯一标识符,我们有标准的 ISBN 书籍代码。除此之外,我们还提供了书籍的标题(Book-Title 列)、作者(Book-Author)、出版年份(Year-of-Publication)和出版社(Publisher)。还提供了缩略图封面图片的 URL,对应三种尺寸——小(Image-URL-S)、中(Image-URL-M)和大(Image-URL-L)。

最后,BX-Book-Ratings 表包含实际的评分。该表结构简单,有三个列——User-ID,用于进行评分的用户;书的 ISBN;以及Book-Rating,表示分数。评分在 1 到 10 的范围内表示,数值越高越好。数值0表示隐含的评分。

此数据集以 SQL 和 CSV 格式提供,打包为 ZIP 存档。请从www2.informatik.uni-freiburg.de/~cziegler/BX/BX-CSV-Dump.zip下载 CSV 版本。

在您的计算机上某个位置解压缩文件。

加载数据

加载这个数据集将会更具挑战性,因为我们必须处理三个不同的文件,以及由于数据本身的特殊性。以下是BX-Users.csv文件的前几行,在一个纯文本编辑器中:

图片

我们必须明确处理以下格式特殊性,否则导入可能会失败:

  • 列之间用;分隔,而不是更常见的逗号或制表符

  • 缺失值用字符串NULL表示

  • 第一行是标题行,代表列名

  • 数据被双引号"包围,并且数据本身中的双引号通过反斜杠转义,例如,"1273";"valladolid, \"n/a\", spain";"27"

幸运的是,CSV 包在读取文件时提供了额外的选项来传递所有这些信息:

julia> users = CSV.read("BX-Users.csv", header = 1, delim = ';', missingstring = "NULL", escapechar = '\\') 

加载这个表可能需要一点时间,但最终我们会尝到成功的甜头——278858行已加载到内存中!

图片

我们将使用相同的方法来加载书籍和排名表:

julia> books = CSV.read("BX-Books.csv", header = 1, delim = ';', missingstring = "NULL", escapechar = '\\') 
271379×8 DataFrames.DataFrame 
# output omitted # 

julia> books_ratings = CSV.read("BX-Book-Ratings.csv", header = 1, delim = ';', missingstring = "NULL", escapechar = '\\') 
1149780×3 DataFrames.DataFrame 
# output omitted # 

太棒了!我们现在已经将所有三个表加载到内存中作为DataFrames

处理缺失数据

在数据科学中,当记录中的一个字段没有存储数据值时,会出现缺失值——换句话说,当我们没有一行中的列值时。这是一个常见的场景,但无论如何,它可能会对数据的可用性产生重大的负面影响,因此需要明确处理。

DataFrames中的方法是使用Missing类型标记缺失值。默认行为是传播缺失值,从而污染涉及missing的数据操作——也就是说,涉及有效输入的操作,missing将返回missing或失败。因此,在大多数情况下,需要在数据清洗阶段解决缺失值问题。

处理缺失值的最常见技术如下:

  • 删除:包含缺失变量的行被删除(也称为逐行删除)。这种方法的不利之处在于会导致信息丢失。然而,如果我们有大量数据并且不完整的记录不多(比如,低于 10%),这是最简单的方法,也是最常用的方法。

  • 插补:使用某些技术推断缺失值,通常为均值中位数众数。然而,你需要小心,因为这会人为地减少数据集的变异。作为替代,可以使用预测模型通过应用统计方法来推断缺失值。

你可以在docs.julialang.org/en/v1.0/manual/missing/的文档中了解更多关于 Julia 处理缺失值的信息,而关于处理缺失数据的理论方面的更深入讨论可以在datascience.ibm.com/blog/missing-data-conundrum-exploration-and-imputation-techniques/找到。

数据分析和准备

让我们从用户开始,感受一下数据:

julia> using DataFrames 
julia> describe(users, stats = [:min, :max, :nmissing, :nunique, :eltype]) 

输出如下:

我们选择了一些关键统计数据——最小值和最大值、缺失值和唯一值的数量,以及数据类型。不出所料,作为表的主键的User-ID列从1开始,一直升到278858,没有缺失值。然而,Age列显示出数据错误明显的迹象——最大年龄是244岁!让我们通过使用Gadfly绘制数据来看看我们有什么:

julia> using Gadfly 
julia> plot(users, x = :Age, Geom.histogram(bincount = 15)) 

输出如下:

我们将年龄数据绘制成直方图,将数据分为 15 个区间。我们有一些异常值表明年龄不正确,但大部分数据分布在预期的范围内,即 80-90 岁。由于 100 岁以后的年龄几乎不可能正确,让我们将其删除。最简单的方法是过滤掉所有年龄大于100的行:

julia> users = users[users[:Age] .< 100, :] 
ERROR: ArgumentError: unable to check bounds for indices of type Missing 

哎呀!我们的Age列有无法比较的missing值。我们也可以删除这些值,但在这个情况下,缺失的年龄似乎更多是用户未披露信息的一个症状,而不是数据错误。因此,我更倾向于保留这些行,并用有效值替换缺失数据。问题是,用哪个值?使用mean进行插补似乎是一个不错的选择。让我们计算一下:

julia> using Statistics 
julia> mean(skipmissing(users[:Age])) 
34.75143370454978 

我们使用了skipmissing函数遍历所有非缺失的Age值并计算*均值。现在,我们可以使用这个值结合coalesce来替换缺失值:

julia> users[:Age] = coalesce.(users[:Age], mean(skipmissing(users[:Age]))) 
278858-element Array{Real,1}: 
 34.75143370454978 
 18 
 34.75143370454978 
 17 
 34.75143370454978 
# output omitted # 

我们实际上是用一个新数组替换了users DataFrame中的Age列,这个新数组是通过将coalesce应用于相同的Age列得到的。请注意coalesce调用中的点,表示它是逐元素应用的。

太好了——最后,我们需要删除那些错误的年龄:

julia> users = users[users[:Age] .< 100, :] 
278485×3 DataFrame
 # output omitted # 

julia> head(users) 

输出如下:

看起来不错!

我们完成了用户的数据处理,接下来让我们转向书籍数据:

julia> describe(books, stats = [:nmissing, :nunique, :eltype]) 

输出如下:

数据看起来干净多了——首先,没有缺失值。然后,查看nunique的计数,我们可以知道有些书籍有相同的标题,而且有相当多的作者出版了两本以上的书籍。最后,这些书籍来自* 17,000 家出版社。

到目前为止,一切顺利,但让我们看看Year-Of-Publication

julia> maximum(skipmissing(books[Symbol("Year-Of-Publication")])) 
2050 

julia> minimum(skipmissing(books[Symbol("Year-Of-Publication")])) 
0 

这里有些不对劲——我们有一些出版年份没有意义。有些太遥远,而有些则过于未来。我想知道分布是什么样的。让我们再渲染一个直方图:

julia> plot(books, x = Symbol("Year-Of-Publication"), Geom.histogram) 

输出如下:

图片

大部分数据看起来是正确的,但也有一些异常值。我们可以看看这些值:

julia> unique(books[Symbol("Year-Of-Publication")]) |> sort 
116-element Array{Union{Missing, Int64},1}: 
    0 
 1376 
 1378 
# output omitted # 
 2037 
 2038 
 2050 

初看之下,我们可以去掉出版年份等于0的行。我们还可以安全地假设所有出版日期大于数据收集年份(2004)的行也是错误的,因此可以删除。至于剩下的部分,虽然很难说怎么办,但仍然很难相信人们会为公元中叶出版的书籍评分。让我们只保留19702004年间出版的书籍:

julia> books = books[books[Symbol("Year-Of-Publication")] .>= 1970, :] 
264071×8 DataFrame 
# output omitted # 

julia> books = books[books[Symbol("Year-Of-Publication")] .<= 2004, :] 
263999×8 DataFrame 
# output omitted # 

julia> plot(books, x = Symbol("Year-Of-Publication"), Geom.histogram) 

输出如下:

图片

这要好得多,完全合理。

最后,让我们检查评分:

julia> describe(books_ratings) 

输出如下:

图片

没有缺失值,这很好。Book-Rating的值在0(隐式评分)和10之间,其中110代表显式评分。不过,0.0的中位数有点令人担忧,让我们看看:

julia> plot(books_ratings, x = Symbol("Book-Rating"), Geom.histogram) 

输出如下:

图片

结果表明,大部分评分是隐式的,因此设置为0。这些对我们推荐系统来说并不相关,所以让我们去掉它们:

julia> books_ratings = books_ratings[books_ratings[Symbol("Book-Rating")] .> 0, :] 
433671×3 DataFrame 
# output omitted # 

julia> plot(books_ratings, x = Symbol("Book-Rating"), Geom.histogram) 

这里是输出:

图片

我们做得很好!在我们的提取、转换、加载ETL)流程中还有一步——让我们通过匹配列将三个DataFrame合并起来,从而删除各种孤儿条目(那些在其他所有表中没有对应行的条目)。

首先,我们将书籍评分和书籍连接起来:

julia> books_ratings_books = join(books_ratings, books, on = :ISBN, kind = :inner) 
374896×10 DataFrame 
# output omitted # 

我们使用join方法,指定我们想要连接的两个DataFrame,以及连接列和我们要进行的连接类型。内部连接要求结果包含键值在第一个和第二个DataFrame中都存在的行。

现在,让我们与用户数据连接起来:

julia> books_ratings_books_users = join(books_ratings_books, users, on = Symbol("User-ID"), kind = :inner) 
374120×12 DataFrame 
# output omitted # 

我们的数据集现在只包含有效数据,这些数据被很好地打包在一个单独的DataFrame中。

由于我们的评分是在110的范围内,并不是所有的评分都可以被认为是这本书的推荐。确实,大多数排名都在5以上,但5对于有用的推荐来说还不够好。让我们简化一下数据,通过假设任何以8开头的排名代表正面评论,并会形成强有力的推荐。因此,我们只保留这些行,丢弃其余的:

julia> top_ratings = books_ratings_books_users[books_ratings_books_users[Symbol("Book-Rating")] .>= 8, :] 
217991×12 DataFrame 
# output omitted # 

这看起来不错,但只需稍作调整,使列名更符合 Julia 的风格,就会更好:

julia> for n in names(top_ratings) rename!(top_ratings, n => Symbol(replace(string(n), "-"=>""))) end 

我们将遍历每个列名并删除破折号。这样,我们就能使用这些名称,而无需每次都显式使用Symbol构造函数。我们最终会得到以下名称:

julia> names(top_ratings) 
12-element Array{Symbol,1}: 
 :UserID 
 :ISBN 
 :BookRating 
 :BookTitle 
 :BookAuthor 
 :YearOfPublication 
 :Publisher 
 :ImageURLS 
 :ImageURLM 
 :ImageURLL 
 :Location 
 :Age 

我们越来越接*了——我们数据处理工作流程的最后一步是检查每个用户的评论数量。我们从一个用户那里得到的评论越多,我们就能创建出更好的偏好配置文件,从而产生更相关、质量更高的推荐。基本上,我们想要得到每个用户的评分计数,然后得到每个计数的计数(即,有多少个一星、二星、三星,等等,直到我们有的十个评分):

julia> ratings_count = by(top_ratings, :UserID, df -> size(df[:UserID])[1]) 

在这里,我们按UserIDtop_ratings数据进行分组,并使用size函数作为我们的聚合函数,它返回一个维度元组——我们从其中检索其第一个维度。我们将得到以下结果,其中x1列包含相应用户提供的评分数量:

输出如下:

图片

想知道这些数据会揭示什么?让我们来看看:

julia> describe(ratings_count) 

这里是输出结果:

图片

评分的最小数量是1,而最活跃的用户提供的评分不少于5491,*均每个用户的评论数量约为5。考虑到那些评论少于5的用户,他们的推荐可能相当薄弱,我们最好移除那些数据不足的用户:

julia> ratings_count = ratings_count[ratings_count[:x1] .>= 5, :] 
7296×2 DataFrame 
# output omitted # 

我们只保留至少有5个评分的用户。现在让我们看看评分的分布情况:

julia> plot(ratings_count, x = :x1, Geom.histogram(maxbincount = 6)) 

输出如下:

图片

看起来,大多数用户都有多达1000个评分。那么,有很多评论的异常值用户呢?

julia> ratings_count[ratings_count[:x1] .> 1000, :] 

输出如下:

图片

只有3个用户。我们最好移除他们,以免他们扭曲我们的结果:

julia> ratings_count = ratings_count[ratings_count[:x1] .<= 1000, :] 
7293×2 DataFrame 
# output omitted # 

现在我们有了最终用户的列表,下一步就是从top_ratings DataFrame中移除所有其他用户。再次,让我们使用内连接——这相当直接:

julia> top_ratings = join(top_ratings, ratings_count, on = :UserID, kind = :inner) 
150888×13 DataFrame 
# output omitted # 

就这样,我们的数据准备好了。干得好!

如果你想,你可以使用CSV.write将此数据保存到文件中:

julia> CSV.write("top_ratings.csv", top_ratings) 

如果你遇到难以跟上进度的问题,请不要担心。在接下来的几段中,我将解释如何加载本章支持文件中提供的现成数据集。

训练我们的数据模型

机器学习可以根据方法和所使用的数据类型分为四种主要类型:

  • 监督

  • 无监督

  • 半监督

  • 强化

在监督学习中,我们从一个包含训练(或教学)数据的数据集开始,其中每个记录都有标签,代表输入(让我们称其为 X),以及输出值(命名为 Y)。然后,算法的任务是确定一个从输入到输出的函数 f,使得 Y = f(X)。一旦这个函数被确定,它就可以用于新数据(即未标记的新输入)来预测输出。根据需要计算输出类型的类型,如果输出需要分配给某个实体的类别(例如,它代表分类数据),则将使用分类算法。或者,如果输出类型是数值,我们将处理回归问题。

在无监督机器学习中,我们有输入,但没有输出。在这种情况下,一旦我们使用学习数据集来训练我们的系统,主要目标将是数据聚类,即生成不同的输入簇,并能够将新数据分配到最合适的簇中。

如其名所示,半监督代表两种先前描述方法的混合,这两种方法都适用于我们的数据包含标记和未标记记录的情况。

在强化学习中,算法会了解其先前决策的成功情况。基于此,算法修改其策略以最大化结果。

根据学习风格和要解决的问题的具体问题,有众多算法可以应用。对于监督学习,我们可以使用回归(线性或逻辑回归)、决策树或神经网络,仅举几例。对于无监督学习,我们可以选择 k-means 聚类或 Apriori 算法。

由于我们的数据已标记(我们拥有每个用户的评分),我们正在处理一个监督机器学习问题。对于我们的测试案例,由于我们的数据以矩阵形式表示,我们将采用一种称为矩阵分解MF)的算法。

你可以在以下链接中了解更多关于各种机器学习算法及其选择的信息:

docs.microsoft.com/en-us/azure/machine-learning/studio/algorithm-choice blog.statsbot.co/machine-learning-algorithms-183cc73197c elitedatascience.com/machine-learning-algorithms machinelearningmastery.com/a-tour-of-machine-learning-algorithms/

缩小我们的数据集

在大规模上训练机器学习模型通常需要(很多)强大的计算机和大量的时间。如果你在阅读这本书的时候没有这些条件,我准备了一个较小的数据集,这样你就可以完成我们的项目。

在我的四核、16 GB RAM 笔记本电脑上,使用完整的top_ratings数据训练推荐器花费了超过 24 小时。如果你有兴趣,可以自由尝试。它也可以在github.com/PacktPublishing/Julia-Projects/blob/master/Chapter07/data/large/top_ratings.csv.zip下载。

然而,如果你想在阅读本章时跟随代码,请下载本章支持文件中提供的top_ratings.csv文件,网址为github.com/PacktPublishing/Julia-Projects/blob/master/Chapter07/data/top_ratings.csv。我将使用这个较小的文件中的数据来完成本章的剩余部分。

下载文件后,你可以使用CSV.read函数将其内容加载到top_ratings变量中:

julia> top_ratings = CSV.read("top_ratings.csv")  
11061×13 DataFrame 
# output omitted # 

训练数据与测试数据

在机器学习实现中,一个常见的策略是将数据分成训练集(大约 80-90%)和测试集(剩余的 10-20%)。首先,我们将初始化两个空的DataFrames来存储这些数据:

julia> training_data = DataFrame(UserID = Int[], ISBN = String[], Rating = Int[]) 
0×3 DataFrame 

julia> test_data = DataFrame(UserID = Int[], ISBN = String[], Rating = Int[]) 
0×3 DataFrame 

接下来,我们将遍历我们的top_ratings,并将内容放入相应的DataFrame中。我们将用 10%的数据进行测试——所以每次迭代,我们将生成一个介于110之间的随机整数。显然,得到10的概率是十之一,因此当我们得到它时,我们将相应的行放入测试数据集中。否则,它将进入训练集,如下所示:

julia> for row in eachrow(top_ratings)
 rand(1:10) == 10 ? 
 push!(test_data, convert(Array, row[[:UserID, :ISBN, :BookRating]])) :
 push!(training_data, convert(Array, row[[:UserID, :ISBN, :BookRating]]))
 end 

DataFrameRow推送到另一个DataFrame上没有标准的方法,所以我们使用了一种推荐的方法,即将行转换为Array并使用push!将其推送到DataFrame中。我们的训练集和测试集现在已准备就绪。

对于我来说,它们看起来是这样的,但由于数据是随机生成的,所以对你们来说可能会有所不同:

julia> test_data 
1056×3 DataFrame
 # output omitted # 

julia> training_data 
10005×3 DataFrame 
# output omitted # 

如果你希望我们使用相同的数据集,你可以从本章的支持文件中下载数据存档(可在github.com/PacktPublishing/Julia-Projects/blob/master/Chapter07/data/training_data.csvgithub.com/PacktPublishing/Julia-Projects/blob/master/Chapter07/data/test_data.csv分别下载)并按以下方式读取:

julia> test_data = CSV.read("data/test_data.csv") 
julia> training_data = CSV.read("data/training_data.csv") 

基于机器学习的推荐

Julia 的生态系统提供了访问Recommendation.jl包的途径,这是一个实现多种算法的包,包括个性化和非个性化推荐。对于基于模型的推荐器,它支持 SVD、MF 和基于 TF-IDF 评分算法的内容推荐。

另外还有一个非常好的替代方案——ScikitLearn.jl包(github.com/cstjean/ScikitLearn.jl)。这个包在 Julia 中实现了 Python 非常流行的 scikit-learn 接口和算法,支持 Julia 生态系统中的模型以及 scikit-learn 库中的模型(通过PyCall.jl)。Scikit 网站和文档可以在scikit-learn.org/stable/找到。它非常强大,绝对值得记住,尤其是在构建用于生产使用的效率极高的推荐器时。出于学习目的,我们将坚持使用Recommendation,因为它提供了更简单的实现。

使用Recommendation进行推荐

对于我们的学习示例,我们将使用Recommendation。它是所有可用选项中最简单的,它是一个很好的教学工具,因为它将允许我们进一步实验其即插即用的算法和可配置的模型生成器。

然而,在我们能够进行任何有趣的事情之前,我们需要确保我们已经安装了该包:

 pkg> add Recommendation#master  
 julia> using Recommendation 

请注意,我使用的是#master版本,因为在撰写本书时,标记的版本尚未完全更新以支持 Julia 1.0。

使用Recommendation设置推荐器的流程包括三个步骤:

  1. 设置训练数据

  2. 使用可用的算法之一实例化和训练推荐器

  3. 一旦训练完成,请求推荐

让我们实施这些步骤。

设置训练数据

Recommendation使用DataAccessor对象来设置训练数据。这可以通过一组Event对象来实例化。Recommendation.Event是一个表示用户-项目交互的对象。它定义如下:

struct Event 
    user::Int 
    item::Int 
    value::Float64 
end 

在我们的案例中,user字段将代表UserIDitem字段将映射到 ISBN,而value字段将存储Rating。然而,我们需要做更多的工作来将我们的数据格式化为Recommendation所需的格式:

  1. 首先,我们的 ISBN 数据存储为字符串,而不是整数。

  2. 其次,在内部,Recommendation构建了一个user * item的稀疏矩阵并存储相应的值,使用顺序 ID 设置矩阵。然而,我们的实际用户 ID 是很大的数字,Recommendation将设置一个非常大的稀疏矩阵,从最小用户 ID 到最大用户 ID。

这意味着,例如,在我们的数据集中我们只有 69 个用户(如unique(training_data[:UserID]) |> size所确认的),最大的 ID 是 277,427,而对于书籍,我们有 9,055 个独特的 ISBN。如果我们这样做,Recommendation将创建一个 277,427 x 9,055 的矩阵,而不是 69 x 9,055 的矩阵。这个矩阵将会非常大,稀疏且效率低下。

因此,我们需要进行一些额外的数据处理,将原始用户 ID 和 ISBN 映射到从 1 开始的顺序整数 ID。

我们将使用两个Dict对象来存储从UserIDISBN列到推荐器的顺序用户和书籍 ID 的映射。每个条目都将具有以下形式dict[original_id] = sequential_id

julia> user_mappings, book_mappings = Dict{Int,Int}(), Dict{String,Int}() 

我们还需要两个计数器来跟踪和增加顺序 ID:

julia> user_counter, book_counter = 0, 0 

我们现在可以准备我们的训练数据的Event对象:

julia> events = Event[] 
julia> for row in eachrow(training_data) 
 global user_counter, book_counter user_id, book_id, rating = row[:UserID], row[:ISBN], row[:Rating] haskey(user_mappings, user_id) || (user_mappings[user_id] = (user_counter += 1)) haskey(book_mappings, book_id) || (book_mappings[book_id] = (book_counter += 1)) push!(events, Event(user_mappings[user_id], book_mappings[book_id], rating)) end

这将用Recommendation.Event实例填充事件数组,它代表一个独特的UserIDISBNRating组合。为了给您一个概念,它看起来像这样:

julia> events 
10005-element Array{Event,1}: 
 Event(1, 1, 10.0) 
 Event(1, 2, 8.0) 
 Event(1, 3, 9.0) 
 Event(1, 4, 8.0) 
 Event(1, 5, 8.0) 
 # output omitted #

请记住这个非常重要的方面——在 Julia 中,for循环定义了一个新的作用域。这意味着在for循环外部定义的变量在循环内部是不可访问的。为了使它们在循环体内部可见,我们需要将它们声明为global

现在,我们准备设置我们的DataAccessor

julia> da = DataAccessor(events, user_counter, book_counter) 

构建和训练推荐器

到目前为止,我们已经拥有了实例化推荐器所需的一切。一个非常高效且常见的实现使用 MF——不出所料,这是Recommendation包提供的选项之一,所以我们将使用它。

矩阵分解

MF 背后的想法是,如果我们从一个大型的稀疏矩阵开始,比如用来表示用户 x 个人资料评分的矩阵,那么我们可以将其表示为多个较小且密集的矩阵的乘积。挑战在于找到这些较小的矩阵,使得它们的乘积尽可能接*原始矩阵。一旦我们有了这些,我们就可以在原始矩阵中填补空白,使得预测值与矩阵中现有的评分一致:

我们的用户 x 书籍评分矩阵可以表示为较小且密集的用户和书籍矩阵的乘积。

为了执行矩阵分解,我们可以使用几种算法,其中最流行的是 SVD 和随机梯度下降SGD)。Recommendation使用 SGD 来执行矩阵分解。

代码如下:

julia> recommender = MF(da) 
julia> build(recommender) 

我们实例化一个新的 MF 推荐器,然后构建它——即训练它。构建步骤可能需要一段时间(在高端计算机上使用本章支持文件中提供的小数据集,可能需要几分钟)。

如果我们想调整训练过程,由于 SGD 实现了矩阵分解的迭代方法,我们可以向构建函数传递一个max_iter参数,请求它提供最大迭代次数。理论上,我们进行的迭代越多,推荐效果越好——但训练模型所需的时间也会更长。如果您想加快速度,可以使用max_iter30或更少的构建函数——build(recommender, max_iter = 30)

我们可以传递另一个可选参数学习率,例如,build (recommender, learning_rate=15e-4, max_iter=100)。学习率指定了优化技术应在每次迭代之间如何变化。如果学习率太小,优化需要运行很多次。如果太大,那么优化可能会失败,生成比前几次迭代更差的结果。

提供推荐

现在我们已经成功构建并训练了我们的模型,我们可以请求它提供推荐。这些推荐由recommend函数提供,该函数接受一个推荐器实例、用户 ID(来自训练矩阵中可用的 ID)、推荐数量以及一个包含书籍 ID 的数组作为其参数:

julia> recommend(recommender, 1, 20, [1:book_counter...]) 

使用这一行代码,我们检索具有推荐器 ID 1的用户推荐,这对应于原始数据集中的UserID 277427。我们请求最多20个推荐,这些推荐是从所有可用的书籍中挑选出来的。

我们得到一个包含书籍 ID 和推荐得分的Pair数组的数组:

20-element Array{Pair{Int64,Float64},1}: 
 5081 => 19.1974 
 5079 => 19.1948 
 5078 => 19.1946 
 5077 => 17.1253 
 5080 => 17.1246 
 # output omitted # 

测试推荐

最后,我们的基于机器学习的推荐系统已经准备好了。它无疑将为任何书店的用户体验带来显著提升。但在我们开始宣传它之前,我们应该确保它是可靠的。记住,我们留出了 10%的数据集用于测试目的。想法是将推荐与测试数据中的实际评分进行比较,以查看两者之间的相似程度;也就是说,数据集中的实际评分中有多少被推荐了。根据用于训练的数据,您可能想测试是否做出了正确的推荐,同时也确保没有包含不良推荐(即,推荐器不会建议得到低评分的项目,这表明不喜欢)。由于我们只使用了 8、9 和 10 的评分,我们不会检查是否提供了低排名的推荐。我们只关注检查有多少推荐实际上是用户数据的一部分。

由于测试数据使用的是原始用户和配置文件 ID,而我们的推荐器使用的是归一化、顺序 ID,我们需要一种方法在两者之间转换数据。我们已经有 user_mappingsbook_mappings 字典,它们将原始 ID 映射到推荐器 ID。然而,我们还需要反向映射。所以,让我们首先定义一个用于反转字典的辅助函数:

julia> function reverse_dict(d) Dict(value => key for (key, value) in d) end 

这很简单,但非常有用——我们现在可以使用这个函数根据推荐器 ID 查找原始 ID。例如,如果我们想测试用户 1 的推荐,我们需要检索此用户的实际评分,因此我们需要原始 ID。我们可以通过以下代码轻松获取它:

julia> reverse_dict(user_mappings)[1] 
277427 

这同样适用于书籍映射——例如,ID 为 5081 的推荐对应于原始数据集中的 ISBN 981013004X

julia> reverse_dict(book_mappings)[5081] 
"981013004X" 

好吧,让我们检查为 UserID 277427(推荐器用户 1)预留的测试数据:

julia> user_testing_data = test_data[test_data[:UserID] .== reverse_dict(user_mappings)[1], :] 
8×3 DataFrame 

输出结果如下:

testing_data DataFrame by doing an element-wise comparison—for each row, it checks if the UserID column equals 277427 (which is the ID returned by reverse_dict(user_mappings)[1], remember?). If yes, then the  whole row is added to user_testing_data.

要检查推荐与实际评分的配置文件,最简单的方法是将推荐向量与评分向量进行交集。所以,首先要做的是将测试评分放入一个向量中,从 DataFrame 中取出:

julia> test_profile_ids = user_testing_data[:, :ISBN] 
8-element Array{Union{Missing, String},1}: 
 "0060006641" 
 "0441627404" 
 "0446600415" 
 "0671727079" 
 "0671740504" 
 "0671749897" 
 "0836218817" 
 "0842370668" 

我们只选择所有行的 ISBN 列数据,作为一个 Array

对推荐进行同样的操作要复杂一些。此外,由于我预计我们可能需要测试不同的推荐器设置和不同数量的推荐,最好定义一个函数将推荐转换为 ISBN 向量,这样我们就可以轻松地重用代码:

julia> function recommendations_to_books(recommendations) 
           [reverse_dict(book_mappings)[r[1]] for r in recommendations] 
       end 

recommendations_to_books 函数将推荐器生成的 id => score 对的向量作为其唯一参数,并将其转换为原始 ISBN 向量:

julia> recommendations_to_books(recommend(recommender, 1, 20, [1:book_counter...])) 
20-element Array{String,1}: 
 "981013004X" 
 "1856972097" 
 "1853263656" 
 "1853263133" 
 "1857231791"
 # output omitted #

recommendations_to_books 函数输出了 20 本推荐书的 ISBN。

现在,我们已经拥有了所有检查推荐与评分的部件:

julia> intersect(test_profile_ids, recommendations_to_books(recommend(recommender, 1, 500, [1:book_counter...]))) 
1-element Array{Union{Missing, String},1}: 
 "0441627404"  

我们使用交集函数来检查第一个向量——我们为测试放置的书籍列表中的哪些元素也出现在第二个向量中,即推荐中。我们必须请求 500 个推荐,因为在 9,055 本书池中击中这八本测试书的可能性非常小。这是由于我们处理的数据非常少,但在生产环境中,可能涉及数十亿行,我们会得到更多重叠的数据。

让我们看看前五个推荐是什么:

julia> for i in recommendations_to_books(recommend(recommender, 1, 20, [1:book_counter...])) top_ratings[top_ratings.ISBN .== i, :BookTitle] |> println end  

Union{Missing, String}["Fun With Chinese Characters Volume 1"] 
Union{Missing, String}["Fantasy Stories (Story Library)"] 
Union{Missing, String}["The Wordsworth Complete Guide to Heraldry (Wordsworth Reference)"] 
Union{Missing, String}["The Savoy Operas (Wordsworth Collection)"] 
Union{Missing, String}["Against a Dark Background"] 

在 IJulia 笔记本中,我们甚至可以查看封面,从而使用封面的 URL 渲染一小块 HTML:

thumbs = DataFrame(Thumb = String[]) 

for i in recommendations_to_profiles(recommend(recommender, 1, 20, [1:book_counter...])) 
    push!(thumbs, top_ratings[top_ratings.ISBN .== i, :ImageURLL]) 
end 

for img in thumbs[:, :Thumb] 
    HTML("""<img src="img/$(img)">""") |> display 
end 

输出结果如下:

太棒了!我们做得很好。我们驯服了一个非常复杂的数据集,进行了高级分析,然后我们优化了它在推荐器中的使用。然后我们成功训练了推荐器,并使用它为我们的用户生成书籍推荐。

部署和使用Recommendation包非常简单,正如你肯定已经体会到的。再次强调,就像大多数数据科学项目一样,ETL 步骤是最复杂的。

了解混合推荐系统

当使用基于模型的推荐系统时,有一些明显的优势。如前所述,可扩展性是最重要的因素之一。通常,模型的大小远小于初始数据集,因此即使是对于非常大的数据样本,模型也足够小,可以允许高效的使用。另一个好处是速度。查询模型所需的时间,与查询整个数据集所需的时间相比,通常要小得多。

这些优势源于模型通常是在线外准备的,这使得推荐几乎可以即时完成。但是,由于没有免费的性能,这种方法也带来了一些显著的缺点——一方面,它不够灵活,因为构建模型需要相当的时间和资源,这使得更新变得困难和昂贵;另一方面,因为它没有使用整个数据集,预测可能不够准确。

就像所有事情一样,没有一劳永逸的解决方案,最佳方法取决于你手头的数据和你需要解决的问题。然而,并不总是需要基于内存的与基于模型的相对比。更进一步,也不一定只有一个推荐系统。实际上,多个算法和方法的组合可以有效地弥补单一类型推荐系统的局限性。这种架构被称为混合。由于篇幅限制,我们不会涵盖任何混合推荐系统的实现,但我想要给你一个可能的方法的概述。我将仅引用罗宾·伯克在《自适应网络》第十二章中的分类,该章节标题为《混合网络推荐系统》。整个章节可以在网上免费获取,链接为www.researchgate.net/publication/200121024_Hybrid_Web_Recommender_Systems。如果你对这个主题感兴趣,我强烈推荐阅读。

摘要

推荐系统代表了一个非常活跃和动态的研究领域。它们最初只是机器学习算法和技术的一个边缘应用,但由于其实际的商业价值,*年来已经成为主流。如今,几乎所有主要的编程语言都提供了强大的推荐系统库,并且所有主要的在线业务都以某种形式使用推荐系统。

Julia 是一种非常适合构建推荐系统的语言,因为它具有出色的性能。尽管这种语言还很年轻,但我们已经有了一些有趣的包可供选择。

现在,你对基于模型的推荐系统及其实现工作流程有了扎实的理解——无论是在理论层面还是实践层面。此外,在我们旅途中,我们还接触到了更多高级的数据处理技巧,使用了DataFrames,这是 Julia 数据科学工具库中一个无价的工具。

在下一章中,我们将进一步提高我们对DataFrames的掌握,因为我们将会学习 Julia 元编程的秘密,同时开发一个无监督的机器学习系统。

第八章:利用无监督学习技术

我们的无监督机器学习项目取得了成功,我们正在稳步成为推荐系统专家。现在是时候告别我们整洁标记的数据的安全,进入未知领域。是的,我说的就是无监督机器学习。在本章中,我们将训练一个模型,帮助我们找到大量数据中的隐藏模式。既然我们在学习 Julia 的旅程上已经走了这么远,是时候摘下训练轮子,迎接我们的第一个客户了。

开个玩笑——目前,我们将假装,但确实会解决一个可能成为初级数据科学家第一个任务的机器学习问题。我们将帮助我们的想象中的客户发现支持他们广告策略的关键见解,这是他们开始在旧金山运营的重要部分。

在此过程中,我们将了解以下内容:

  • 无监督机器学习是什么以及何时以及如何使用它

  • 聚类的基础知识,这是最重要的无监督学习任务之一

  • 如何借助查询进行高效的数据清洗

  • Julia 中的元编程

  • 使用聚类进行无监督机器学习模型的训练和运行

技术要求

Julia 的包生态系统正在不断发展,并且每天都有新的包版本发布。大多数时候这是个好消息,因为新版本带来了新功能和错误修复。然而,由于许多包仍在测试版(版本 0.x)中,任何新版本都可能引入破坏性更改。因此,书中展示的代码可能无法正常工作。为了确保您的代码将产生与书中描述相同的结果,建议使用相同的包版本。以下是本章使用的外部包及其特定版本:

CSV@v.0.4.3
DataFrames@v0.15.2
DataValues@v0.4.5
Gadfly@v1.0.1
IJulia@v1.14.1
Query@v0.10.1

为了安装特定版本的包,您需要运行:

pkg> add PackageName@vX.Y.Z 

例如:

pkg> add IJulia@v1.14.1

或者,您也可以通过下载章节提供的Project.toml文件并使用pkg>实例化来安装所有使用的包:

julia> download("https://raw.githubusercontent.com/PacktPublishing/Julia-Programming-Projects/master/Chapter08/Project.toml", "Project.toml")
pkg> activate . 
pkg> instantiate

无监督机器学习

在第七章《推荐系统机器学习》中,我们学习了监督机器学习。我们使用了数据中的各种特征(如用户的评分)来执行分类任务。在监督机器学习中,我们有点像老师——我们向算法提供大量的示例,一旦它获得足够的数据(并且其训练完成),就能够对新项目进行概括,并推断它们的类别或类别。

但并非所有数据都适合这些任务。有时我们的数据根本没有任何标签。想象一下像网站流量日志或牙科诊所客户预约这样多样化的项目。这些只是未经分类的原始观察结果,没有任何含义。在这种情况下,数据分析师会使用无监督机器学习算法。

无监督机器学习用于在未标记的数据中发现隐藏的结构和模式。这是一个非常强大的机器学习任务,在各个领域都得到了成功应用,例如市场营销(用于识别具有相似购买偏好的客户群体)、医学(用于发现肿瘤)、IT 安全(通过标记异常用户行为或网络流量)、税收征收(警告可能的逃税行为),以及许多其他领域。

如果我们简单地忽略提供数据分类的特征,任何监督机器学习任务都可以被视为无监督的。例如,如果我们不想考虑物种列,我们可以使用著名的鸢尾花数据集进行无监督学习。这将使我们只剩下未标记的花瓣长度和宽度,它们可以形成有趣的簇。

如我们在第一章,“Julia 编程入门”,中看到的,setosa可以可靠地识别,因为它花瓣长度和宽度始终较低。但是versicolorvirginica呢?就不那么明显了。你可以在下面的图中查看:

图片

图表显示了setosa在几乎所有图表中形成独特的簇——但是versicolorvirginica没有。这是无监督学习。简单,对吧?

不完全是——这比那更复杂。在我们的鸢尾花例子中,我们在用颜色编码图表时有点作弊,因为我们按物种给图表着色。所以,数据并不是真正未标记的。在一个真正的无监督学习场景中,图表将看起来像这样,所有物种信息都被移除:

图片

即使没有颜色和标签,由于点的分布相同,我们仍然可以轻松地识别出setosa簇。但是,显然,如果没有物种标签,我们根本不知道它代表什么。这是一个非常重要的观点——算法不能自己标记簇。它可以在各种数据点之间识别出相似度,但它无法解释那意味着什么(它不会知道它是setosa)。这个观点的一个推论是,没有定义簇的正确方法。它们是探索性数据挖掘技术的结果——就像探索未知领土一样,走不同的路径(从不同的角度观察数据)会导致不同的结果。用一句名言来概括,簇在观察者的眼中。

无监督机器学习最常见的任务定义如下:

  • 聚类(或聚类分析):聚类用于识别和分组那些与其他潜在分组或聚类中的项目相比更相似的对象。这种比较是通过使用数据特征中的一些度量来完成的。

  • 异常检测:它用于标记那些不符合由数据集中其他项目定义的预期模式的项目。它们很重要,因为通常,异常代表某种问题,如银行或税务欺诈、软件错误或医疗状况。

在本章的剩余部分,我们将专注于聚类——这是一个非常有用且宝贵的无监督机器学习任务。我们将更深入地研究聚类背后的理论,然后我们将使用旧金山商业数据实现一个无监督机器学习项目。

聚类

如您现在可能已经意识到的,在数据科学领域,几乎总是有多个途径来解决问题。在算法层面,根据数据的特定性和我们试图解决的特定问题,我们通常会有不止一个选择。丰富的选择通常是好消息,因为一些算法可能比其他算法产生更好的结果,具体取决于具体情况。聚类也不例外——有一些众所周知的算法可用,但我们必须了解它们的优点和局限性,以避免得到无关的聚类。

以 Scikit-learn 这个著名的 Python 机器学习库为例,通过使用一些玩具数据集来说明这一点。这些数据集产生易于识别的图表,使得人类能够轻松识别聚类。然而,应用无监督学习算法将导致截然不同的结果——其中一些与我们的模式识别能力所告诉我们的明显相矛盾:

前面的四个图示说明了以下内容:

  1. 两个同心圆状聚类

  2. 两个曲线

  3. 三个团块

  4. 由均匀分布的值构成的单个聚类的正方形

对聚类进行颜色编码将产生以下图表:

利用我们天生的模式识别能力,我们可以轻松地看到定义良好的聚类。

如果对于您来说聚类非常明显,您可能会惊讶地发现,在机器学习领域,事情并不那么清晰。以下是一些最常见算法如何解释数据(以下图表和测试的所有细节都可以在 Scikit-learn 网站上找到:scikit-learn.org):

图表显示了颜色编码的聚类以及八个知名算法的计算时间——MiniBatchKMeans、Affinity Propagation、Mean Shift、Spectral Clustering、Ward、Agglomerative Clustering、DBSCAN 和 Birch。

旧金山商业数据分析

作为本章的学习项目,让我们假设我们被一家新客户,著名的 ACME 招聘公司雇佣。他们是一家主要的人力资源公司,并希望在旧金山开设一家新办公室。他们正在制定一项非常雄心勃勃的营销策略来配合他们的开业。ACME 希望通过广告牌进行户外宣传活动;在公交车、出租车和自行车上张贴海报进行交通广告;并通过邮寄传单进行直接邮件营销。

他们针对的是商业客户,并来找我们帮助他们做两件事:

  • 他们想知道最佳的竞选区域,在哪里放置广告牌,在哪些公交线路上投放广告,以及向哪些邮寄地址发送传单。

  • 他们希望了解市场的招聘需求,以便他们可以联系到具有所需资格的专业人士。

我们的计划是使用有关旧金山注册公司的数据库,并使用无监督学习,即聚类,来检测公司密度最高的区域。这就是 ACME 应该花费他们的广告费用的地方。一旦我们确定了他们将要针对的公司,我们就能看到他们从事的活动领域。我们的客户将使用这些信息来评估他们的招聘需求。

在数据方面,我们有一个良好的开端,因为旧金山市公开提供了大量有趣的数据,可以在data.sfgov.org网站上找到。浏览该网站,我们可以找到一个注册纳税企业的数据库。它提供了丰富的信息,包括名称、地址、开业和停业日期(如果企业已关闭)、地理位置坐标(有时还有街区名称)等。

您可以通过点击工具栏中的导出按钮,从data.sfgov.org/Economy-and-Community/Map-of-Registered-Business-Locations/ednt-jx6u下载文件,或者使用直接下载 URL:data.sfgov.org/api/views/ednt-jx6u/rows.csv?accessType=DOWNLOAD

然而,我强烈建议使用本章支持文件中提供的文件,以确保我们使用确切相同的数据,并在你继续操作时得到相同的结果。请从github.com/PacktPublishing/Julia-Programming-Projects/blob/master/Chapter08/data/Map_of_Registered_Business_Locations.csv.zip下载。

对于每个条目,我们还得到了 北美行业分类系统NAICS)代码,该代码是(联邦统计机构用于分类商业机构以收集、分析和发布与美国商业经济相关的统计数据的标准)。这很重要,因为我们将使用它来识别最常见的商业类型,这将帮助我们的客户吸引相关候选人。

在我们的数据集中,NAICS 代码表示为一个范围,例如,4400–4599。幸运的是,我们还得到了相应活动部门的名称。在这个例子中,4400–4599 代表 零售业

是时候加载数据并进行切片和切块了!到现在为止,我相信你已经知道了该怎么做:

julia> using CSV, DataFrames  
julia> df = CSV.read("Map_of_Registered_Business_Locations.csv") 

使用 describe(df) 可以给我们提供关于每一列的宝贵信息。为了简洁起见,我在下一张截图里只包括了 nuniquenmissing,但你可以自由地作为练习更详细地检查数据:

图片

检查缺失值的数量和百分比(在 nmissing 列下)以及唯一值的数量(作为 nunique)。我们可以看到,对于 Location Id 列,我们得到了 222871 个唯一值和零缺失条目。唯一的位置 ID 数量等于数据集的行数:

julia> size(df, 1) 
222871  

接下来,Ownership Name 代表注册商业的实体(无论是个人还是另一家公司),而 DBA Name 则代表商业本身的名称。对于这两者,我们可以看到 Number Unique 小于 Location Id,这意味着一些公司由相同的实体拥有——并且一些公司会有相同的名称。进一步查看 Street Address,我们发现大量公司与其他商业共享位置(对于 222871 家公司有 156,658 个唯一的地址)。最后,我们可以看到我们几乎所有的记录都有 CityStateZipcode 信息。

数据集还提供了关于商业注册日期(Business Start Date)、关闭日期(Business End Date)以及在该位置开始和结束运营的日期(分别对应 Location Start DateLocation End Date)的信息。还有更多细节,但它们对于我们分析大多不相关,例如 Parking TaxTransient Occupancy TaxLIC Code 数据(超过 95%的记录缺失)以及 Supervisor DistrictNeighborhoods Analysis BoundariesBusiness Corridor 信息(尽管商业走廊数据在 99.87%的情况下缺失)。

是时候清理我们的数据,为分析做好准备!

使用查询进行数据处理

到目前为止,我们已经看到了如何使用DataFrames API 来操作DataFrame实例。我相信你现在已经知道,我们可以通过使用delete!(df::DataFrame, column_name::Symbol)等方法来删除不感兴趣的列。你可能还记得,在前一章中,你可以通过结合方括号表示法和点元素操作符来过滤DataFrame实例,例如以下示例:

julia> df[df[Symbol("Parking Tax")] .== true, :][1:10, [Symbol("DBA Name"), Symbol("Parking Tax")]] 
DBA Name and Parking Tax columns only) where the business pays parking tax:

图片

现在,如果你认为 Julia 以其美丽的语法和出色的可读性而宠爱我们,而前面的代码既不美丽也不易读——那么,你是对的!虽然前面的语法是可用的,但肯定可以改进。而且,我相信你不会对听到 Julia 的包生态系统已经提供了更好的方法来处理DataFrames而感到惊讶。现在,让我们来看看Query

Query 是一个用于查询 Julia 数据的包。它与多种数据源一起工作,包括 Array、DataFrame、CSV、SQLite、ODBC 等。它提供了过滤、投影、连接和分组功能,并且深受微软的语言集成查询LINQ)的启发。如果你对此不太了解,不要担心;你很快就会看到它的实际应用。

下面是如何重构前面的操作,使用查询来过滤出支付停车税的企业:

julia> @from i in df begin 
           @where i[Symbol("Parking Tax")] == true 
           @select i 
           @collect DataFrame 
       end

如果你熟悉 SQL,你很容易就能识别出熟悉的查询语言结构,如fromwhereselect。这非常强大!

然而,为了将诸如Parking Tax之类的列名转换为符号以便访问我们的数据,不得不使用这种冗长的语法,这确实不方便。在我们开始之前,最好将列名重命名为更符合符号的格式,并将空格替换为下划线。我们将使用DataFrames.rename!函数结合列表推导来完成这项工作:

julia> rename!(df, [n => replace(string(n), " "=>"_") |> Symbol for n in names(df)]) 

rename!函数接受一个DataFrame和一个形式为:current_column_name => :new_current_nameArray{Pair}。我们使用列表推导来构建数组,通过遍历每个当前列名(由names(df)返回),将结果符号转换为字符串,将" "替换为"_",然后将字符串转换回符号。

现在,我们可以使用更简洁的点符号表示法与查询结合,所以前面的代码片段将看起来像这样:

@from i in df begin 
    @where i.Parking_Tax == true 
    @select i 
    @collect DataFrame 
end 

Julia 中的元编程

@ prefix represents a macro—which introduces a very powerful programming technique called metaprogramming.

如果你之前没有听说过它,它基本上意味着一个程序具有在运行时读取、分析、转换,甚至修改自己的能力。一些被称为 同构 的语言,提供了非常强大的元编程功能。在同构语言中,程序本身在内部被表示为程序可以访问并操作的数据结构。Lisp 是典型的同构编程语言,因此这种元编程是通过 Lisp 风格的宏来实现的。它们与 C 和 C++ 中的预处理宏不同,预处理宏在解析和评估之前会操作包含代码的文本文件。

Julia 语言在一定程度上受到 Lisp 语言的影响,也是一种同构语言。因此,在 Julia 中的元编程,我们需要了解两个关键方面——通过表达式和符号表示代码,以及使用宏来操作代码。如果我们把 Julia 程序的执行看作是一系列步骤,元编程就会在解析步骤之后、代码被编译器评估之前介入并修改代码。

学习元编程中的符号和表达式

理解元编程并不容易,所以如果你一开始觉得它不太自然,请不要慌张。我认为其中一个原因是它发生在比我们习惯的常规编程更高的抽象层次上。我希望通过从符号开始讨论,可以使介绍更加具体。我们在整本书中广泛使用了符号,特别是作为各种函数的参数。它们看起来是这样的——:x:scientific:Celsius。正如你可能已经注意到的,符号代表一个标识符,我们非常像使用常量一样使用它。然而,它不仅仅是这样。它代表了一段代码,这段代码不是作为变量来评估,而是用来引用变量本身。

要理解符号和变量之间的关系,一个很好的类比是短语中的单词。以句子为例:理查德很高。在这里,我们理解到 理查德 是一个人的名字,很可能是男性。理查德这个人很高。然而,在句子:理查德有七个字母中,很明显我们现在谈论的不是理查德这个人。假设理查德这个人有七个字母,这并没有太多意义。我们谈论的是单词 理查德 本身。

在 Julia 中,与第一句话(理查德很高)等价的表达方式是 julia> x。在这里,x 被立即评估以产生其值。如果它没有被定义,将会导致错误,如下所示:

julia> x 
ERROR: UndefVarError: x not defined 

Julia 的符号模仿了第二句话,其中我们谈论的是单词本身。在英语中,我们用单引号将单词括起来,写成'Richard',以表明我们不是指人,而是指单词本身。同样,在 Julia 中,我们在变量名前加一个冒号,:x

julia> :x 
:x 
julia> typeof(:x) 
Symbol 

因此,冒号:前缀是一个运算符,它停止评估。未评估的表达式可以通过使用eval()函数或@eval宏按需评估,如下所示:

julia> eval(:x) 
ERROR: UndefVarError: x not defined 

但我们可以超越符号。我们可以编写更复杂的类似符号的语句,例如,:(x = 2)。这工作方式与符号非常相似,但实际上它是一个Expr类型,代表表达式。表达式,像任何其他类型一样,可以通过变量名引用,并且像符号一样,它们可以被评估:

julia> assign = :(x = 2) 
:(x = 2) 
julia> eval(assign) 
2 
julia> x 
2 
Expr type with the assign variable and then eval it. Evaluation produces side effects, the actual value of the variable x now being 2.

更加强大的是,由于Expr是一个类型,它具有暴露其内部结构的属性:

julia> fieldnames(typeof(assign)) 
(:head, :args) 

每个Expr对象都有两个字段——head代表其类型,args代表参数。我们可以通过使用dump()函数查看Expr的内部结构:

julia> dump(assign)
Expr
head: Symbol =
args: Array{Any}((2,))
1: Symbol x
2: Int64 2  

这引出了更加重要的发现。首先,这意味着我们可以通过其属性程序化地操作Expr

julia> assign.args[2] = 3 
3 
julia> eval(assign) 
3 
julia> x 
3 

我们的表达式不再是:(x = 2);现在它是:(x = 3)。通过操作assign表达式的argsx的值现在是3

其次,我们可以使用类型的构造函数程序化地创建Expr的新实例:

julia> assign4 = Expr(:(=), :x, 4) :(x = 4) 
julia> eval(assign4) 4
 julia> x 4 

请注意,在这里我们用括号将等号(=)括起来,以指定一个表达式,因为否则 Julia 会困惑,认为我们想要在那里立即执行赋值操作。

引用表达式

之前的过程,即我们在:(...)中包装一个表达式以创建Expr对象,被称为引用。它也可以使用 quote 块来完成。quote 块使引用变得更加容易,因为我们可以将看起来像常规代码的内容传递给它们(而不是将所有内容转换为符号),并且支持引用多行代码以构建随机复杂的表达式:

julia> quote 
           y = 42 
           x = 10 
       end
 julia> eval(ans) 
10
 julia> y 
42
 julia> x 
10 

字符串插值

就像字符串插值一样,我们可以在表达式中引用变量:

julia> name = "Dan" 
"Dan" 

julia> greet = :("Hello " * $name) 
:("Hello " * "Dan") 

julia> eval(greet) 
"Hello Dan" 

现在,我们终于有了理解宏的知识。它们是语言结构,在代码解析之后、评估之前执行。它可以可选地接受一个参数元组,并且必须返回一个Expr。生成的Expression将被直接编译,因此我们不需要对它调用eval()

例如,我们可以将之前的greet表达式作为一个宏实现为可配置版本:

julia> macro greet(name) 
           :("Hello " * $name) 
       end 
@greet (macro with 1 method)
julia> @greet("Adrian") 
"Hello Adrian" 
macro keyword and are invoked using the @... syntax. The brackets are optional when invoking macros, so we could also use @greet "Adrian".

宏是功能强大的语言结构,允许在运行完整程序之前对代码的部分进行自定义。官方 Julia 文档有一个很好的例子来说明这种行为:

julia> macro twostep(arg) 
         println("I execute at parse time. The argument is: ", arg) 
         return :(println("I execute at runtime. The argument is: ", $arg)) 
       end 
@twostep (macro with 1 method) 

我们定义了一个名为 twostep 的宏,其主体调用 println 函数将文本输出到控制台。它返回一个表达式,当评估时,也会通过相同的 println 函数输出一段文本。

现在,我们可以看到它的实际应用:

julia> ex = macroexpand(@__MODULE__, :(@twostep :(1, 2, 3))); 
I execute at parse time. The argument is: $(Expr(:quote, :((1, 2, 3)))) 
macroexpand, which takes as an argument the module in which to expand the expression (in our case, @__MODULE__ stands for the current module) and an expression that represents a macro invocation. The call to macroexpand converts (expands) the macro into its resulting expressions. The output of the macroexpand call is suppressed by appending ; at the end of the line, but the resulting expression is still safely stored in ex. Then, we can see that the expanding of the macro (its parsing) takes place because the I execute at parse time message is output. Now look what happens when we evaluate the expression, ex:
julia> eval(ex) 
I execute at runtime. The argument is: (1, 2, 3) 

输出了 I execute at runtime 消息,但没有输出 I execute at parse time 消息。这是一件非常强大的事情。想象一下,如果我们有一些计算密集型或耗时操作,而不是简单的文本输出。在一个简单的函数中,我们每次都必须运行这段代码,但使用宏,这只需在解析时运行一次。

关于宏的结束语

除了它们非常强大之外,宏也非常方便。它们可以用最小的开销提供很多功能,并且可以简化接受表达式作为参数的函数的调用。例如,@time 是一个非常有用的宏,它在测量执行时间的同时执行一个 Expression。而且,最棒的是,我们可以将参数表达式作为 常规 代码传递,而不是手动构建 Expr

julia> @time rand(1000); 
  0.000007 seconds (5 allocations: 8.094 KiB) 

宏——以及一般意义上的元编程——是强大的概念,需要整本书来详细讨论。我们必须在这里停下来,以便回到我们的机器学习项目。ACME 招聘公司热切地等待我们的发现。我建议查看官方文档 docs.julialang.org/en/stable/manual/metaprogramming/

从 Query.jl 基础开始

可以用标准方式添加 Query 包——pkg> add Query。一旦使用 Query 将其引入作用域,它就提供了一个丰富的 API 用于查询 Julia 数据源,其中 DataFrames 是最常见的数据源。使用 @from 宏来启动查询。

@from

查询的一般结构如下:

@from var in data_source begin 
    # query statements here 
end 

begin...end 块中,var 代表 data_source 中的行。查询语句每行给出一个,可以包括任何可用的查询命令的组合,例如 @select@orderby@join@group@collect。让我们更详细地看看其中最重要的几个。

@select

@select 查询命令,类似于其 SQL SELECT 对应命令,表示要返回哪些值。其一般语法是 @select condition,其中 condition 可以是任何 Julia 表达式。最常见的情况是我们希望返回整行,在这种情况下,我们只需传递 var 本身。例如,让我们创建一个新的 DataFrame 来保存购物清单:

julia> shopping_list = DataFrame(produce=["Apples", "Milk", "Bread"], qty=[5, 2, 1]) 

输出如下:

一个酷(极客!)且实用的购物清单。

我们可以用以下方式 @select 整行:

@from p in shopping_list begin 
    @select p 
end 

这不是很实用,因为这基本上返回整个 DataFrame,但我们也可以使用点符号引用列,例如,p.produce

julia> @from p in shopping_list begin 
           @select p.produce 
       end 
3-element query result 
 "Apples" 
 "Milk" 
 "Bread" 

由于 @select 接受任何随机的 Julia 表达式,我们可以自由地按需操作数据:

julia> @from p in shopping_list begin 
           @select uppercase(p.produce), 2p.qty 
       end 
3-element query result 
 ("APPLES", 10) 
 ("MILK", 4) 
 ("BREAD", 2) 
produce and two times the qty.

然而,更好的方法是返回 NamedTuple,使用特殊的查询花括号语法:

julia> @from p in shopping_list begin 
           @select { produce = uppercase(p.produce), qty = 2p.qty } 
       end 

输出如下:

在这里,我们传递了 NamedTuple 的键和值,但它们不是强制的。然而,如果我们想要有正确命名的列(谁不想呢,对吧?):

julia> @from p in shopping_list begin 
           @select { uppercase(p.produce), 2p.qty } 
       end 

输出如下:

没有明确的标签,query 将分配如 __1____2__ 等之类的列名。这并不太易读!

@collect

你可能已经注意到在前面的屏幕截图中的返回值类型是 query result。一个查询将返回一个迭代器,可以进一步用于遍历结果集的各个元素。但我们可以使用 @collect 语句将结果实体化到特定的数据结构中,最常见的是 ArrayDataFrame。如下所示:

julia> @from p in shopping_list begin 
           @select { PRODUCE = uppercase(p.produce), double_qty = 2p.qty } 
           @collect 
       end 

我们得到以下内容:

默认情况下,@collect 将生成一个 NamedTuple 元素的 Array。但我们可以传递一个额外的参数来指定我们想要的数据类型:

julia> @from p in shopping_list begin 
           @select {PRODUCE = uppercase(p.produce), double_qty = 2p.qty} 
           @collect DataFrame 
       end 

输出看起来如下:

我们的结果现在是一个 DataFrame

@where

最有用的命令之一是 @where,它允许我们过滤数据源,以便只返回满足条件的元素。类似于 @select,条件可以是任何任意的 Julia 表达式:

julia> @from p in shopping_list begin 
           @where p.qty < 2 
           @select p 
           @collect DataFrame 
       end 

我们得到以下输出:

只有面包的 qty 小于 2

通过范围变量,过滤功能可以变得更加强大。这些变量类似于属于 query 表达式的新变量,可以使用 @let 宏引入:

julia> @from p in shopping_list begin 
           @let weekly_qty = 7p.qty 
           @where weekly_qty > 10 
           @select { p.produce, week_qty=weekly_qty } 
           @collect DataFrame 
       end 

输出如下:

在这里,你可以看到如何在 begin...end 块内定义一个名为 weekly_qty 的局部变量,其值等于 7 * p.qty。我们使用了 @let 宏来引入新变量。在下一行,我们使用它来过滤出 weekly_qty 小于 10 的行。然后最终,我们选择它并将其收集到一个 DataFrame 中。

@join

让我们让事情变得更加有趣:

julia> products_info = DataFrame(produce = ["Apples", "Milk", "Bread"], price = [2.20, 0.45, 0.79], allergenic = [false, true, true]) 

输出如下:

我们实例化一个新的 DataFrame,称为 products_info,它包含关于购物清单中物品的重要信息——它们的价格以及它们是否可以被认为是过敏原。我们可以使用 DataFrames.hcat!products_info 的一些列追加到 shopping_list 中,但再次强调,语法并不那么优雅,这种方法也不够灵活。我们已经习惯了 Julia,我们喜欢这种方式!幸运的是,Query 提供了一个 @join 宏:

shopping_info = @from p in shopping_list begin 
    @join pinfo in products_info on p.produce equals pinfo.produce 
    @select { p.produce, p.qty, pinfo.price, pinfo.allergenic } 
    @collect DataFrame 
end 
shopping_list as p, adding an inner join, @join, with products_info as pinfo, on the condition that p.produce equals pinfo.produce. We basically put together the produce and qty columns from shopping_list DataFrame with the price and allergenic columns from products_info. The resulting DataFrame can now be referenced as shopping_info:

@join 命令的一般语法如下:

@from var1 in datasource1 begin 
    @join var2 in datasource2 on var1.column equals var2.column  
end 

查询提供了 @join 的两种其他变体:分组连接和左外连接。如果您想了解更多信息,请查看官方文档www.queryverse.org/Query.jl/stable/querycommands.html#Joining-1

@group

@group 语句按某些属性对元素进行分组:

julia> @from p in shopping_info begin 
           @group p.produce by p.allergenic 
           @collect 
       end 
2-element Array{Grouping{Bool,String},1}: 
 ["Apples"] 
 ["Milk", "Bread"]  

还不错,但我们真正想要的是总结数据。Query 在此提供了名为 split-apply-combine(也称为 dplyr)的功能。这需要一个聚合函数,该函数将根据 Grouping 变量来折叠数据集。如果这太抽象了,一个例子肯定会澄清一切。

假设我们想要获取过敏物品的数量以及它们名称的逗号分隔列表,这样我们就可以知道要避免什么:

@from p in shopping_info begin
    @group p by p.allergenic into q
    @select { allergenic = key(q),
    count = length(q.allergenic),
    produce = join(q.produce, ", ") }
    @collect DataFrame
end  
q variable and then pass the aggregation function, length, to get a count of the values of the allergenic column. We then use the join function to concatenate the values in the produce column.

结果将是一个两行的 DataFrame

图片

@orderby

查询还提供了一个名为 @orderby 的排序宏。它接受一个属性列表,用于应用排序。类似于 SQL,默认情况下是升序排序,但我们可以通过使用 descending 函数来更改它。

给定我们之前定义的 products_info DataFrame,我们可以轻松地按需对其进行排序,例如,首先按价格最高的产品排序,然后按产品名称排序:

julia> @from p in products_info begin 
           @orderby descending(p.price), p.produce 
           @select p 
           @collect DataFrame 
        end 
@orderby to sort the values in the source. Unsurprisingly, the resulting DataFrame will be properly sorted with the most expensive products on top:

图片

好吧,这确实是一个相当大的绕路!但现在我们已经了解了伟大的 Query 包,我们准备好高效地切割和剖析我们的数据了。让我们开始吧!

准备我们的数据

我们的数据清理计划是只保留在旧金山、加利福尼亚注册的企业,我们有它们的地址、邮编、NAICS 代码和业务位置,并且它们尚未关闭(因此没有业务结束日期)且未搬迁(没有位置结束日期)。

使用 DataFrame API 应用过滤器将是繁琐的。但有了 Query,这就像散步一样简单:

pkg> add DataValues 
julia> using DataValues 
julia> clean_df = @from b in df begin 
 @where lowercase(b.City) == "san francisco" && b.State == "CA" &&
 ! isna(b.Street_Address) && ! isna(b.Source_Zipcode) &&
 ! isna(b.NAICS_Code) && ! isna(b.NAICS_Code_Description) &&
 ! isna(b.Business_Location) &&
 occursin(r"\((.*), (.*)\)", get(b.Business_Location)) &&
 isna(b.Business_End_Date) && isna(b.Location_End_Date)
 @select { b.DBA_Name, b.Source_Zipcode, b.NAICS_Code, 
 b.NAICS_Code_Description, b.Business_Location }
 @collect DataFrame
end 

我们可以看到 @where 过滤器是如何应用的,要求 lowercase(b.City) 等于 "san francisco",并且 b.State 等于 "CA"。然后,我们使用 ! isna 确保只保留 b.Street_Addressb.Source_Zipcodeb.NAICS_Codeb.NAICS_Code_Descriptionb.Business_Location 不为空的行。isna 函数由 DataValues 包提供(该包由 Query 本身使用),这就是为什么我们要添加并使用它的原因。

我们还确保 b.Business_Location 与对应于地理坐标的特定格式匹配。最后,我们确保 b.Business_End_Dateb.Location_End_Date 实际上是缺失的。

执行查询会产生一个包含* 57,000 行的新的 DataFrame

下一步是从 clean_df 数据中提取 Business_Location 列中的地理坐标。同样,Query 来帮忙:

clean_df_geo = @from b in clean_df begin
 @let geo = split(match(r"(\-?\d+(\.\d+)?),\s*(\-?\d+(\.\d+)?)", 
 get(b.Business_Location)).match, ", ")
 @select {b.DBA_Name, b.Source_Zipcode, b.NAICS_Code,
 b.NAICS_Code_Description,
 lat = parse(Float64, geo[1]), long = parse(Float64, geo[2])}
 @collect DataFrame
end 

我们充分利用了范围变量特性(由 @let 定义)来引入一个 geo 变量,它使用 matchBusiness_Location 数据中提取经纬度对。接下来,在 @select 块内部,geo 数组中的两个值被转换为适当的浮点值并添加到结果 DataFrame 中:

我们完成了!我们的数据现在已整洁地表示在 clean_df_geo DataFrame 中,包含企业的名称、邮政编码、NAICS 代码、NAICS 代码描述、纬度和经度。

如果我们运行 describe(clean_df_geo),我们会看到我们有 56,549 家企业,有 53,285 个独特的名称,只有 18 个 NAICS 代码描述。我们不知道公司分布在多少个邮政编码中,但很容易找到:

julia> unique(clean_df_geo[:, :Source_Zipcode]) |> length 
79 

我们的业务在旧金山市的 79 个邮政编码内注册。

基于聚类的无监督机器学习

Julia 的包生态系统提供了一个专门的库用于聚类。不出所料,它被称为 聚类。我们可以简单地执行 pkg> add Clustering 来安装它。Clustering 包实现了一些常见的聚类算法——k-means、亲和传播、DBSCAN 和 kmedoids。

K-means 算法

K-means 算法是最受欢迎的算法之一,在广泛的应用中提供了良好的结果和良好的性能的*衡组合。然而,一个复杂的问题是,我们事先必须给它指定簇的数量。更确切地说,这个数字称为 k(因此算法名称的首字母),代表质心的数量。质心是代表每个簇的点。

K-means 算法采用迭代方法——它使用由种子过程定义的算法放置质心,然后它将每个点分配到其对应的质心,即最*的均值。算法在收敛时停止,也就是说,当点分配在新一轮迭代中不改变时。

算法种子

有几种方法可以选择质心。聚类提供了三种,其中一种是随机(在聚类中标记为 :rand 选项),它随机选择一个点的子集作为种子(因此所有质心都是随机的)。这是经典 k-means 的默认种子策略。还有 k-means++,这是一种在 2007 年由 David Arthur 和 Sergei Vassilvitskii 提出的更好变体(标记为 :kmpp),它随机选择一个簇中心,然后根据第一个中心搜索其他中心。最后一种可用方法是中心性种子(:kmcen),它选择具有最高中心性的样本。

寻找拥有最多企业的区域

在上一节中,我们成功清理了数据,现在它整洁地存储在clean_df_geo``DataFrame中。如果你在数据清理过程中遇到任何问题,你可以直接使用本章支持文件中提供的clean_df_geo.tsv文件从头开始加载数据集(github.com/PacktPublishing/Julia-Programming-Projects/blob/master/Chapter08/data/clean_df_geo.tsv.zip)。为了加载它,你只需要运行以下命令:

julia> using CSV 
julia> clean_df_geo = CSV.read("clean_df_geo.tsv", delim = '\t', nullable = false) 

因此,我们想要识别企业密度最高的地区。一种方法是通过无监督机器学习,根据邮政编码和注册的企业数量来识别地区。

我们将使用:zipcode列中的数据以及该地区注册的企业数量来训练我们的模型。我们需要每个邮政编码的企业数量:

julia> model_data = @from b in clean_df_geo begin 
    @group b by b.Source_Zipcode into g 
    @let bcount = Float64(length(g)) 
    @orderby descending(bcount) 
    @select { zipcode = Float64(get(key(g))), businesses_count = bcount } 
    @collect DataFrame 
end 

我们对clean_df_geo``DataFrame执行查询,按:Source_Zipcode分组到g。我们将当前邮政编码的企业数量存储在bcount范围变量中,这是由length(g)返回的,但在转换成Float64之前。我们这样做的原因是,正如我们马上就会看到的,聚类期望输入为Float64,因此这将节省我们后续的另一个处理步骤。回到我们的查询。我们还通过bcount进行排序,以便我们人类更好地理解数据(对训练模型不是必需的)。最后,我们实例化一个新的DataFrame,包含两列,一个邮政编码和一个businesses_count,同时不要忘记将邮政编码也转换为Float64,原因与之前相同。在转换key(g)时,请注意,我们首先调用了get函数。这是因为,在查询块中,计算值表示为DataValues,要访问包装的值,我们需要调用get

我们的训练数据由79个邮政编码及其对应的企业数量组成。前 22 个地区每个地区有超过 1000 家企业,其余地区的数量急剧下降:

julia> last(model_data) 

输出如下:

你可能还记得Gadfly,这是我们在第一章《Julia 编程入门》中使用的 Julia 绘图库,用于可视化 Iris 花朵数据集。让我们用它快速浏览一下我们的数据:

julia> using Gadfly 
julia> plot(model_data, x=:businesses_count, Geom.histogram) 

这将生成以下直方图:

我们可以很容易地看出,大多数地区只有一个注册的企业,然后是一些只有几个企业的其他地区。我们可以安全地从我们的训练数据集中移除这些,因为它们对我们的客户没有帮助。我们唯一需要做的是在查询model_data时添加@where bcount > 10过滤器,在@let@orderby语句之间:

model_data = @from b in clean_df_geo begin 
    @group b by b.Source_Zipcode into g 
    @let bcount = Float64(length(g)) 
    @where bcount > 10 
    @orderby descending(bcount) 
    @select { zipcode = Float64(get(key(g))), businesses_count = bcount } 
    @collect DataFrame 
end 

一旦我们移除了所有少于10家公司的区域,我们就只剩下28个邮编。

训练我们的模型

只需一小步,我们就可以准备好训练我们的模型。我们需要将DataFrame转换为数组,并重新排列数组的维度,以便DataFrame的列成为行。在新结构中,每个列(邮编和计数对)被视为一个训练样本。让我们来做:

julia> training_data = permutedims(convert(Array, model_data), [2, 1]) 

我们的训练数据已经准备好了!是时候好好利用它了:

julia> using Clustering
julia> result = kmeans(training_data, 3, init=:kmpp, display=:iter)

 Iters               objv        objv-change | affected  
------------------------------------------------------------- 
      0       6.726516e+06 
      1       4.730363e+06      -1.996153e+06 |        0 
      2       4.730363e+06       0.000000e+00 |        0 
K-means converged with 2 iterations (objv = 4.73036279655838e6) 

我们通过调用同名函数来使用 k-means 算法。作为参数,我们提供training_data数组,并给它三个聚类。我们希望将区域分为三个等级——低、中、高密度。训练不应该超过几秒钟。由于我们提供了display=:iter参数,我们在每次迭代时都会得到渐进的调试信息。对于种子算法,我们使用了 k-means++(:kmpp)。

解释结果

现在,我们可以看看点是如何分配的:

julia> result.assignments 
28-element Array{Int64,1}: 
 3 
 3 
 3 
 1 
 1 
 # some 1 values omitted from the output for brevity 
 1 
 1 
 2 
 2 
 # some 2 values omitted from the output for brevity 
 2 
 2 

数组中的每个元素对应于model_data中相同索引处的元素。让我们合并数据,使其更容易跟踪:

julia> model_data[:cluster_id] = result.assignments 
28-element Array{Int64,1}: 
# output truncated #

现在让我们看看我们最终得到的是什么:

julia> model_data

输出结果如下:

我们可以看到,前三个邮编被分配到了聚类3,最后八个被分配到了聚类2,其余的分配到了聚类1。你可能已经注意到,聚类的 ID 并不遵循实际的计数值,这是正常的,因为数据是无标签的。我们必须解释聚类的含义。而且我们的算法已经决定,商业密度最高的区域将保持在聚类3,低密度区域在聚类2,*均密度区域在聚类1。使用Gadfly绘制数据将证实我们的发现:

julia> plot(model_data, y = :zipcode, x = :businesses_count, color = result.assignments, Geom.point, Scale.x_continuous(minvalue=0, maxvalue=5000), Scale.y_continuous(minvalue=94050, maxvalue=94200), Scale.x_continuous(format=:plain)) 

它生成了以下图表:

极好!现在我们可以通知我们的客户,最佳的目标区域是邮编 94110、94103 和 94109,这样他们就可以接触到这些城市密集区域中的 11,965 家企业。他们还想知道这些是哪些公司,让我们准备一个列表:

companies_in_top_areas = @from c in clean_df_geo begin 
       @where in(c.Source_Zipcode, [94110, 94103, 94109]) 
       @select c 
       @collect DataFrame 
end 

我们使用在聚类步骤中提取的邮编来过滤clean_df_geo数据集:

我们最终在三个区域代码中集中了 11,965 家公司。让我们使用geo坐标绘制这些点:

julia> plot(companies_in_top_areas, y = :long, x = :lat, Geom.point, Scale.x_continuous(minvalue=36, maxvalue=40), Scale.y_continuous(minvalue=-125, maxvalue=-120), color=:Source_Zipcode) 

输出结果如下:

如预期,位置很接*,但有一个异常值,其坐标远远偏离。也许我们的数据中存在错误。使用查询,我们可以轻松地移除这个罪魁祸首:

julia> companies_in_top_areas = @from c in companies_in_top_areas begin 
           @where c.lat != minimum(companies_in_top_areas[:lat]) 
           @select c 
           @collect DataFrame 
      end 

通过我们清理后的列表,我们现在可以探索这些公司的活动领域。这将帮助我们的客户接触到符合市场需求的候选人,如下所示:

julia> activities = @from c in companies_in_top_areas begin 
           @group c by c.NAICS_Code_Description into g 
           @orderby descending(length(g)) 
           @select { activity = key(g), number_of_companies = length(g) } 
           @collect DataFrame 
       end 

那很简单:

目标区域内的所有公司都活跃在仅18个领域,其中房地产业是最常见的一个。当然,我们的客户的高级管理人员会欣赏一张图表:

julia> plot(activities, y=:number_of_companies, Geom.bar, color=:activity, Scale.y_continuous(format=:plain), Guide.XLabel("Activities"), Guide.YLabel("Number of companies")) 

这就是我们所得到的:

是的,图表清楚地显示,房地产业是大多数企业参与的活动,其次是技术和零售业。

精炼我们的发现

到目前为止,进展顺利,但几乎有 12,000 家公司的列表仍然难以处理。我们可以通过将其分解为位于邻*地区的商业集群来帮助我们的客户。这与之前的工作流程相同。首先,我们提取我们的训练数据:

julia> model_data = @from c in companies_in_top_areas begin 
           @select { latitude = c.lat, longitude = c.long } 
           @collect DataFrame 
       end 

输出如下:

现在我们对维度进行排列,以设置符合聚类所需的数据格式(就像我们之前做的那样):

julia> training_data = permutedims(convert(Array{Float64}, model_data), [2, 1]) 

我们的训练数组已准备就绪!

我们将使用相同的 k-means 算法,并使用 k-means++ 种子。

请注意,k-means 通常不是聚类地理位置数据的最佳选择。DBSCAN 通常更适合,我建议您在生产应用中考虑它。例如,当处理超过 180 度的接*点时,k-means 算法会失败。对于我们的示例项目和我们所处理的数据,k-means 工作得很好,但请记住这个限制。

训练方式相同。我们将选择 12 个集群,以便每个组大约有 1,000 家公司:

julia> result = kmeans(training_data, 12, init=:kmpp, display=:iter) 
  # output truncated 
K-means converged with 24 iterations (objv = 0.28192820139520336) 

这次需要 24 次迭代才能达到收敛。让我们看看我们得到了什么:

julia> result.counts 
12-element Array{Int64,1}: 
 1076 
 1247 
  569 
 1180 
 1711 
 1191 
  695 
    1 
 1188 
   29 
 1928 
 1149 

大部分数据分布均匀,但我们可以找到一些没有那么多企业的集群。绘制数字给我们一个清晰的画面:

julia> plot(result.counts, Geom.bar, y=result.counts, Guide.YLabel("Number of businesses"), Guide.XLabel("Cluster ID"), color=result.counts) 

这里是图表:

现在,我们可以将集群分配粘贴到 companies_in_top_areas DataFrame

julia> companies_in_top_areas[:cluster_id] = result.assignments 

在地图上可视化我们的集群

为了更好地理解我们的数据,从点密度和位置邻*度的角度,我们可以使用 Gadfly 绘制一个图表:

julia> plot(companies_in_top_areas, color=:cluster_id, x=:lat, y=:long) 

输出如下:

我们可以看到相当好的集群分布,所以我们的方法有效!

然而,如果我们能够在地图上显示集群,那就更好了。不幸的是,目前 Julia 中没有简单的方法来做这件事,所以我们将使用第三方工具。

PlotlyJS (github.com/sglyon/PlotlyJS.jl) 提供了相关的功能,但鉴于旧金山地区的坐标紧密排列,我的测试没有产生好的结果。

使用 BatchGeo 快速构建我们的数据地图

BatchGeo (batchgeo.com) 是一个流行的基于地图的数据可视化网络应用程序。它使用来自谷歌的高清地图,并提供一个无需登录、尽管有限但免费的版本,我们可以立即尝试。

BatchGeo 期望一个包含一系列列的 CSV 文件,因此我们的首要任务是设置它。使用 Query,这 couldn't be any simpler。

julia> export_data = @from c in companies_in_top_areas begin 
                           @select { Name = c.DBA_Name, 
 Zip = c.Source_Zipcode, 
                                     Group = string("Cluster $(c.cluster_id)"), 
                                     Latitude = c.lat, Longitude = c.long,  
                                     City = "San Francisco", State = "CA" } 
                           @collect DataFrame 
                     end 

输出如下:

结构化数据可用在一个名为export_data的新DataFrame中。不幸的是,BatchGeo 为免费账户添加了 250 行的限制,因此我们只能将导出限制在顶部 250 行。

这是我们如何导出的方法:

julia> CSV.write("businesses.csv", head(export_data, 250)) 

成功!剩下的唯一事情就是用你喜欢的网络浏览器打开batchgeo.com,并将business.csv文件拖放到指定位置:

  1. 这是通过执行以下截图所示的步骤完成的:

  1. 点击“验证并设置选项”。你会看到列被正确选择,默认值是好的:

  1. 点击“制作地图”将在旧金山地图上渲染我们的聚类:

胜利——我们数据的美丽呈现!

我们还可以禁用聚类,以便每个单独的商业将被绘制:

最后,我们可以保存我们的地图,遵循说明,并获取我们可视化的唯一 URL。我的可以在batchgeo.com/map/7475bf3c362eb66f37ab8ddbbb718b87找到。

太好了,正好赶上与客户的会议!

为 k-means(和其他算法)选择最佳簇数量

根据数据的性质和你要解决的问题,簇的数量可能是一个业务需求,或者它可能是一个明显的选择(就像在我们的案例中,我们想要识别低、中、高商业密度区域,因此最终得到三个簇)。然而,在某些情况下,答案可能并不那么明显。在这种情况下,我们需要应用不同的算法来评估最佳簇数量。

最常见的方法之一是“肘部法”。这是一种迭代方法,我们通过不同的 k 值运行聚类算法,例如在 1 到 10 之间。目标是通过对每个点与其聚类均值之间的*方误差之和进行绘图,来比较总簇内变异。使用可视化,我们可以识别出类似“肘部”的拐点,如下所示:

这就是肘部。

你可以在www.sthda.com/english/articles/29-cluster-validation-essentials/96-determining-the-optimal-number-of-clusters-3-must-know-methods/(R 语言示例)中了解更多关于此内容。

聚类验证

除了选择最佳聚类数量之外,另一个方面是聚类验证,即确定项目如何适合分配的聚类。这可以用来确认确实存在模式,并比较竞争的聚类算法。

聚类提供了三种技术的小型 API 用于聚类验证,包括最常见的轮廓(Silhouettes)。您可以在clusteringjl.readthedocs.io/en/latest/validate.html找到文档,并可以在www.sthda.com/english/articles/29-cluster-validation-essentials/97-cluster-validation-statistics-must-know-methods/了解更多关于验证理论的内容。

摘要

在本章中,我们探讨了使用 Julia 的未监督机器学习技术。我们专注于聚类,这是未监督学习最广泛的应用之一。我们从关于旧金山注册企业的数据集开始,进行了复杂但并不复杂的——多亏了 Query——数据清洗。在这个过程中,我们还了解了元编程,这是一种非常强大的编码技术,也是 Julia 最强大和最具定义性的特性之一。

当我们的数据处于最佳状态,并且掌握了聚类理论的基础之后,我们开始使用 k-means 算法投入实际操作。我们进行了聚类以识别公司密度最高的区域,帮助我们的虚构客户 ACME 招聘定位最佳的广告区域。在确定给 ACME 带来最佳覆盖范围的区域后,我们进行了数据分析,以获取客户所需的活动领域的顶级域名,以便他们能够建立一个相关候选人的数据库。

最后,我们对目标区域的商业地理位置数据进行了聚类,然后在地图上渲染这些数据。我们的客户对我们的发现感到非常满意,他们的营销人员现在拥有了开始规划活动的所有必要信息。恭喜!

在下一章中,我们将离开迷人的机器学习世界,去发现数据科学中的另一个关键概念——时间序列。我们将学习如何在 Julia 中处理日期和时间,如何处理时间序列数据,以及如何进行预测。这难道不令人兴奋吗?

第九章:与日期、时间和时间序列一起工作

我们在机器学习的领域中经历了一段非常令人惊叹和有益的旅程。我们学习了如何使用算法对标记数据进行分类,并将我们的发现应用于提出建议。我们看到了如何通过使用无监督机器学习和聚类算法从原始、未标记的信息中提取商业价值。然而,到目前为止,我们的分析中一直缺少一个关键组成部分——时间维度。

“时间就是金钱”,这句俗语说得好——因此,从小型企业到大型企业,再到政府,以及像欧盟这样的复杂跨国机构,所有规模的组织都会随着时间的推移持续测量和监控大量的经济指标。为了有意义,数据需要在固定的时间间隔内收集,以便分析师能够识别隐藏的结构和模式,并根据过去和现在的条件预测未来的发展。这些值在时间尺度上定期测量,代表时间序列。时间序列分析和预测可以提供极其宝贵的见解,使市场参与者能够理解趋势,并基于准确的历史数据做出明智的决策。

我们将用两章内容,这一章和下一章,来学习时间序列以及进行分析和预测。在这一章中,我们将通过学习以下内容来打下基础:

  • 在 Julia 中与日期和时间一起工作

  • 处理时区信息

  • 使用 TimeSeries 处理时间序列数据

  • 使用强大的 Plots 包绘制时间序列数据

  • TimeArray 数据结构

技术要求

Julia 的包生态系统正在持续发展中,并且每天都有新的包版本发布。大多数时候,这是一个好消息,因为新版本带来了新功能和错误修复。然而,由于许多包仍在测试版(版本 0.x)中,任何新版本都可能引入破坏性更改。因此,书中展示的代码可能停止工作。为了确保您的代码将产生与书中描述相同的结果,建议使用相同的包版本。以下是本章使用的外部包及其特定版本:

IJulia@v1.14.1
MarketData@v0.11.0
Plots@v0.22.0
TimeZones@v0.8.2

为了安装特定版本的包,您需要运行:

pkg> add PackageName@vX.Y.Z 

例如:

pkg> add IJulia@v1.14.1

或者,您也可以通过下载章节提供的 Project.toml 文件并使用 pkg> 实例化来安装所有使用的包,如下所示:

julia> download("https://raw.githubusercontent.com/PacktPublishing/Julia-Programming-Projects/master/Chapter09/Project.toml", "Project.toml")
pkg> activate . 
pkg> instantiate

与日期和时间一起工作

Julia 提供了一个非常丰富的 API 来处理日期和时间信息。所有功能都打包在 Dates 模块中。该模块是语言内建的,因此不需要安装额外的包。为了访问其功能,我们只需声明我们将使用 Dates

日期模块公开了三种主要类型——DateDateTimeTime。它们都是抽象TimeType类型的子类型,分别代表天、毫秒和纳秒精度。

Julia 试图使处理日期和时间尽可能简单。这也是为什么,一方面,它提供了三种不同的类型,每种类型都有自己的时间表示:

  • 一个Date对象映射到一个日期——一个由日、月和年定义的时间实体

  • Time的一个实例是一个时间点——小时、分钟、秒和毫秒,但没有任何关于日期本身的信息

  • 如您从名称中猜到的,DateTime是一个将DateTime组合在一起的对象,指定了一个确切的时间点

另一方面,所有这些类型默认且按设计采用了一种天真的方式来表示日期和时间——也就是说,它们不考虑诸如时区、夏令时或闰秒等因素。这是一种描绘你计算机本地日期和时间的方式,没有任何额外信息。

构建日期和时间

为了构建表示当前日期或时间的新的日期/时间对象,Julia 提供了两个辅助函数,nowtoday。让我们看看读取-评估-打印循环REPL)中的几个例子:

julia> using Dates 

julia> d = today() 
2018-11-08 

julia> typeof(d) 
Date 

julia> dt = now() 
2018-11-08T16:33:34.868 

julia> dt |> typeof 
DateTime 

julia> t = Dates.Time(now()) 
16:34:13.065 

julia> typeof(t) 
Time 

now函数还可以接受一个额外的参数来返回 UTC 时间(不进行夏令时调整):

julia> now(UTC) 
2018-11-08T15:35:08.776 

在内部,所有类型都封装了一个可以通过instant字段访问的Int64值:

julia> dt.instant 
Dates.UTInstant{Millisecond}(63677378014868 milliseconds) 

julia> t.instant 
75147529000000 nanoseconds 

julia> d.instant 
Dates.UTInstant{Day}(737006 days) 

对象的instant属性反映了每种类型的精度级别。

当然,我们也可以使用专门的构造函数实例化表示任何随机时刻的对象:

julia> DateTime(2018) # we can pass just the year as a single argument 
2018-01-01T00:00:00 

julia> DateTime(2018, 6) # passing the year and the month 
2018-06-01T00:00:00 

julia> DateTime(2018, 6, 15) # year, month and day 
2018-06-15T00:00:00 

julia> DateTime(2018, 6, 15, 10) # year, month, day and hour (10 AM) 
2018-06-15T10:00:00 

julia> DateTime(2018, 6, 15, 10, 30) # 15th of June 2018, 10:30 AM  
2018-06-15T10:30:00 

julia> DateTime(2018, 6, 15, 10, 30, 45) # ...and 45 seconds 
2018-06-15T10:30:45 

julia> DateTime(2018, 06, 15, 10, 30, 45, 123) # ... and finally, milliseconds  
2018-06-15T10:30:45.123 

DateTime的构造函数以类似的方式工作——这里有一些例子:

julia> Date(2019) # January 1st 2019 
2019-01-01 

julia> Date(2018, 12, 31) # December 31st 2018 
2018-12-31 

julia> Time(22, 05) # 5 past 10 PM 
22:05:00 

julia> Time(22, 05, 25, 456) # 5 past 10 PM, 25s and 456 milliseconds 
22:05:25.456  

构造函数将阻止我们传递错误的值,从而导致错误。这与其他语言自动执行日期时间算术的情况不同,例如,2018 年 12 月 22 日会自动转换为 2019 年 1 月 1 日。这种情况在 Julia 中不会发生:

julia> Date(2018, 12, 32) 
ERROR: ArgumentError: Day: 32 out of range (1:31) 
Stacktrace: 
 [1] Date(::Int64, ::Int64, ::Int64) at ./dates/types.jl:204 

还有个体日期和时间组件的构造函数——年、月、日、时、分、秒和毫秒。它们返回相应的Period类型实例(我们稍后会详细探讨周期)。周期可以用来创建日期/时间对象:

julia> eleven_hours = Hour(11) 
11 hours 

julia> half_hour = Minute(30) 
30 minutes 

julia> brunch_time = Time(eleven_hours, half_hour) 
11:30:00 

julia> this_year = "2018" 
julia> xmas_month = "12" 
julia> xmas_day = "25" 
julia> Date(Year(this_year), Month(xmas_month), Day(xmas_day)) 
2018-12-25 

将字符串解析为日期和时间

一个常见的需求是将来自外部输入(数据库、文件、用户输入等)的格式化字符串正确解析为相应的日期和时间对象:

julia> Date("25/12/2019", "dd/mm/yyyy") # Christmas day in 2019 
2019-12-25 

julia> DateTime("25/12/2019 14,30", "dd/mm/yyyy HH,MM") # xmas day in 2019, at 2:30 PM 
2019-12-25T14:30:00 

这些是 Julia 识别的特殊日期时间字符及其含义:

  • y:年份数字,例如2015yyyy15yy

  • m:月份数字,例如m => 303

  • u:短月份名称,例如Jan

  • U:长月份名称,例如January

  • e:短星期几,例如Tue

  • E:长星期几,例如Tuesday

  • d:日,例如303

  • H:小时数,例如HH = 00

  • M:分钟数,例如MM = 00

  • S:秒数,例如s = 00

  • s:毫秒数,例如.000

使用这些,我们可以将任何日期/时间字符串解析为正确的对象:

julia> DateTime("Thursday, 1 of February 2018 at 12.35", "E, d of U yyyy at HH.MM") 
2018-02-01T12:35:00 

我们也可以一次性解析多个字符串,作为数组的元素。首先,我们创建一个表示有效日期的字符串数组,格式为yyyy-mm-dd。我们使用列表推导式创建数组,并将其命名为d

julia> d = ["$(rand(2000:2020))-$(rand(1:12))-$(rand(1:28))" for _ in 1:100] 
100-element Array{String,1}:  
 "2001-7-1" 
 "2005-9-4"
 "2018-3-3" 
# output truncated 

接下来,我们可以使用点符号来使用Date构造函数逐个处理数组元素:

julia> Date.(d, "yyyy-mm-dd") 
100-element Array{Date,1}: 
 2001-07-01 
 2005-09-04 
 2018-03-03 
# output truncated  

或者,我们不用字符串来表示日期的格式,而可以使用专门的DateFormat类型:

julia> date_format = DateFormat("yyyy-mm-dd") 
dateformat"yyyy-mm-dd" 

julia> Date.(d, date_format) 
100-element Array{Date,1}: 
2001-07-01 
2005-09-04 
2018-03-03 
# output truncated  

解析大量字符串时,建议使用DateFormat以提高性能。Julia 提供了标准库中的一些格式,例如ISODateTimeFormatRFC1123Format

julia> DateTime("2018-12-25", ISODateTimeFormat) 
2018-12-25T00:00:00 

日期格式化

如果我们可以将日期格式的字符串解析为日期/时间对象,我们也可以做相反的操作。我们可以使用各种格式将日期和时间输出为字符串。例如,看以下内容:

julia> Dates.format(now(), RFC1123Format) 
"Thu, 08 Nov 2018 20:04:35"  

定义其他区域设置

默认情况下,Julia 将使用english区域设置,这意味着星期和月份的名称将是英文。然而,我们可以通过定义额外的区域设置来国际化我们的日期:

julia> spanish_months = ["enero", "febrero", "marzo", "abril", "mayo", "junio", "julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre"] 
12-element Array{String,1} # output truncated 

julia> spanish_days = ["lunes", "martes", "miércoles", "jueves", "viernes", "sábado", "domingo"] 
7-element Array{String,1} # output truncated 

julia> Dates.LOCALES["es"] = Dates.DateLocale(spanish_months, String[], spanish_days, String[]) 
Dates.DateLocale # output truncated 

Dates.DateLocale函数期望四个数组,分别对应月份名称、缩写月份名称、星期名称和缩写星期名称。正如你所看到的,我们没有提供名称的缩写版本。只要我们不尝试使用它们,我们就会没事:

julia> Dates.format(now(), "E, d U yyyy", locale = "es") 
"jueves, 8 noviembre 2018" 

然而,尝试使用缩写星期名称会导致错误:

julia> Dates.format(now(), "e, d U yyyy", locale = "es") 
ERROR: BoundsError: attempt to access 0-element Array{String,1} at index [4] 

使用日期和时间访问器工作

如果我们想访问日期的各个部分(年、月、日),我们可以通过可用的访问器函数检索各个组件:

julia> earth_day = Date(2018, 4, 22) 
2018-04-22 

julia>year(earth_day) # the year 
2018 

julia> month(earth_day) # the month  
4 

API 还公开了复合方法,以简化操作:

julia> monthday(earth_day) # month and day 
(4, 22) 

julia> yearmonthday(earth_day) # year month and day 
(2018, 4, 22) 

对于DateTime也有类似的访问器可用——但没有提供复合方法:

julia> earth_hour = DateTime(2018, 4, 22, 22, 00) 
2018-04-22T22:00:00 

julia> hour(earth_hour) # the hour 
22 

julia> minute(earth_hour) # the minute 
0 

也定义了返回Period对象的替代访问器——它们具有大写名称:

julia> Hour(earth_hour) # a period of 22 hours 
22 hours 

julia> Month(earth_hour) # a period of 4 months 
4 months 

julia> Month(earth_hour) |> typeof |> supertype
 DatePeriod 

julia> supertype(DatePeriod) 
Period 

查询日期

一旦我们有了日期对象,我们就可以获取关于它的丰富额外信息,例如星期几、闰年、年中的天数等等。我们可以使用Dates API 来提取我们日期/时间对象这类信息。

考虑这一点:

julia> yoga_day = Date(2019, 6, 21) # Really, the International Yoga Day does exist! 
2019-06-21 

你想知道瑜伽日 2019 年落在哪一天吗?让我们用我们的 Julia 技能来找出答案:

julia> dayname(yoga_day) 
"Friday" 

如果你需要一周内某天的数值,也可以使用dayofweek(yoga_day),它显然会返回5,因为星期五是每周的第五天。

当然,我们也可以在这里使用本地化名称:

julia> dayname(yoga_day, locale="es") 
"viernes" 

另一个我们可以调用的有用函数是dayofweekofmonth(yoga_day),它会告诉我们这是哪个月的星期五——2019 年 6 月的第三个星期五。

如果你不确定这有什么用,可以举例说明,比如总在每月某一天定期举行的活动。一个很好的例子是我参加的一个每月第三个星期四举行的活动。

我们还可以获取关于月份和年份的大量信息:

julia> monthname(yoga_day, locale="es") # June, with the Spanish locale  
"junio" 

julia> isleapyear(yoga_day) # 2019 is not a leap year 
false 

julia> dayofyear(yoga_day) # It's the 172nd day of 2019 
172 

julia> quarterofyear(yoga_day) # 2nd quarter of 2019 
2  

Dates API 非常丰富,包括比这里展示的更多方法。请访问文档页面 docs.julialang.org/en/v1/stdlib/Dates/index.html#stdlib-dates-api-1

定义日期范围

Julia 允许我们定义日期范围来表示连续的时间段。例如,我们可以将整个年份表示为 1 月 1 日和 12 月 31 日之间的天数范围:

julia> year_2019 = Date(2019, 1, 1):Day(1):Date(2019,12,31) 
2019-01-01:1 day:2019-12-31 

我们创建了一个以一天为步长的日期范围——因此有 365 个项目,因为 2019 年不是闰年:

julia> typeof(year_2019) 
StepRange{Date,Day} 

julia> size(year_2019) 
(365,) 

我们可以使用名为 collect 的函数实例化实际的 Date 对象:

julia> collect(year_2019) 
365-element Array{Date,1}: 
 2019-01-01 
 2019-01-02 
 2019-01-03 
# output truncated 

当然,我们也可以通过索引访问元素,如下所示:

julia> year_2019[100] # day 100 
2019-04-10 

还可以定义具有其他步长的范围,例如月度间隔:

julia> year_2019 = Date(2019, 1, 1):Month(1):Date(2019,12,31) 
2019-01-01:1 month:2019-12-01 

julia> collect(year_2019) # First day of each of the 12 months 
12-element Array{Date,1}: 
 2019-01-01 
 2019-02-01 
 2019-03-01 
# output truncated 

我们可以将任何 Period 对象传递给步长,例如:

julia> year_2019 = Date(2019, 1, 1):Month(3):Date(2019,12,31) # Quarterly 
2019-01-01:3 months:2019-10-01 

julia> collect(year_2019) # The first of each of the 4 quarters 
4-element Array{Date,1}: 
 2019-01-01 
 2019-04-01 
 2019-07-01 
 2019-10-01 

julia> year_2019 = Date(2019, 1, 1):Week(2):Date(2019,12,31) # Bi weekly 
2019-01-01:2 weeks:2019-12-31 

julia> collect(year_2019) 
27-element Array{Date,1}: 
 2019-01-01 
 2019-01-15 
 2019-01-29 
# output truncated 

时期类型和时期算术

我们已经看到了一些 Period 构造函数。这些都是可用的——DayWeekMonthYearHourMinuteSecondMillisecondMicrosecondNanosecondPeriod 类型是一个抽象类型,有两个具体的子类型,DatePeriodTimePeriod

julia> subtypes(Period) 
2-element Array{Any,1}: 
 DatePeriod 
 TimePeriod 

在 Julia 中,Period 代表时间的持续时间。这是一个非常有用的抽象概念,代表了人们经常使用的模糊时间概念。想想看,一个月有多少天——30 或 31 天?28 天呢?还是 29 天?

许多时候,在没有提供更多信息之前,与模糊的抽象概念一起工作可能很有用。以火星旅行的假设情况为例。根据 image.gsfc.nasa.gov/poetry/venus/q2811.html,往返火星将需要 21 个月——9 个月到达,3 个月停留,以及 9 个月返回:

julia> duration_of_trip_to_mars = Month(9) * 2 + Month(3) 
21 months 

这 21 个月究竟需要多长时间,直到我们实际决定何时开始旅行之前是无法确定的:

julia> take_off_day = Date(2020, 1, 15) 
2020-01-15 

现在,我们可以计算宇航员将离开多久:

julia> return_day = take_off_day + duration_of_trip_to_mars 
2021-10-15 

julia> time_diff = return_day - take_off_day 
639 days 

然而,如果由于技术原因,发射日期将推迟五个月,结果将不同:

julia> take_off_day += Month(5) 
2020-06-15 

julia> return_day = take_off_day + duration_of_trip_to_mars 
2022-03-15 

julia> time_diff = return_day - take_off_day 
638 days 

重要的是要记住,与其他编程语言不同,它们对月份的默认长度做出假设——例如 JavaScript 中的 31 天或 PHP 中的 30 天——Julia 采用不同的方法。有关 Period 算术的详细解释,请阅读官方文档 docs.julialang.org/en/v1/stdlib/Dates/index.html#TimeType-Period-Arithmetic-1.

时期不一定是完整的时间量。Julia 允许我们表达不规则的间隔,例如 1 个月和 2 个星期。然而,不规则的时间量(结合了不同类型的时期)将内部表示为不同的类型——不是 Period,而是 CompoundPeriod。以下是它是如何工作的:

julia> isa(Month(3), Period) 
true 

julia> isa(Month(3) + Month(1), Period) 
true 

julia> isa(Month(1) + Week(2), Period)  
false 

julia> isa(Month(1) + Week(2), Dates.CompoundPeriod)  
true 

日期调整

Period 运算非常强大,但有时我们需要表达更灵活的规则,这些规则依赖于其他日期。我想到了下个月的最后一天、下一个星期二,或者每个月的第三个星期一。

对于此类情况,Dates 模块公开了调整器 API。首先,我们有 firstdayof*lastdayof* 函数系列——firstdayofweekfirstdayofmonthfirstdayofquarterfirstdayofyear;以及 lastdayofweeklastdayofmonthlastdayofquarterlastdayofyear,分别。它们接受一个日期/时间对象作为输入,并将其 调整 到指定的时间点:

julia> firstdayofweek(Date(2019, 1, 31)) 
2019-01-28 

在 2019 年,如果一周的第一天包括 1 月 31 日,那么星期一是 28 日。

lastdayof* 函数系列的工作方式类似。但尽管它们很有用,但提供的灵活性还不够。幸运的是,我们有解决方案。如果我们需要除了第一天或最后一天之外的其他日期,我们必须求助于 tonexttoprev 函数对。它们有两种风味。第一种方法接受 TimeType 的子类型(即任何 TimeDateDateTime)和一周中的某一天:

julia> tonext(Date(2019, 4, 1), Saturday) 
2019-04-06 

而下一个愚人节之后的周六落在 2019 年 4 月 7 日。

tonext 的另一种方法甚至更强大——它接受一个类似的 TimeType 和一个函数。它将调整日期,直到函数返回 true。为了了解这有多有用,让我们回到我们之前的会议示例,我每月第三个星期四参加的会议。为了找出下一次会议将在何时举行,我只需要问 Julia:

julia> tonext(today()) do d  # today is Thu 8th of November, 2019 
         dayofweek(d) == Thursday && dayofweekofmonth(d) == 3 
       end 
2018-11-15 

toprev 函数的工作方式与此类似。

另一个函数 filter 允许我们以 Iterator 的形式获取所有匹配的日期。继续我们的会议日程,让我们尝试找出 2019 年所有会议的日期。但我们必须考虑到五月中旬,组织者将参加商务旅行,以及八月是假期月份。因此,在这些时间段内不会有会议。我们该如何表达这一点?结果是,使用 Julia,这相当简单(且易于阅读):

julia> filter(Date(2019):Day(1):Date(2020)) do d 
           ! in(d, Date(2019, 5, 15):Day(1):Date(2019, 5, 31)) &&  
           month(d) != August &&  
           dayofweek(d) == Thursday &&  
           dayofweekofmonth(d) == 3 
   end |> collect  
10-element Array{Date,1}: 
 2019-01-17 
 2019-02-21 
 2019-03-21 
 2019-04-18 
 2019-06-20 
 2019-07-18 
 2019-09-19 
 2019-10-17 
 2019-11-21 
 2019-12-19 

日期的四舍五入

可能会有这样的情况,我们有一个日期/时间,需要计算上一个或下一个完整的时间间隔,例如下一个小时或上一个日子。Dates API 提供了一些用于四舍五入 DateDateTime 对象的方法——floorceiltime。它们非常直观且功能强大:

julia> now() 
2018-11-08T21:13:20.605 

# round down to the nearest hour 
julia> floor(now(), Hour) 
2018-11-08T21:00:00 

# or to the nearest 30 minutes increment 
julia> floor(now(), Minute(30))  
2018-11-08T21:00:00 

# it also works with dates  
julia> floor(today(), Month) # today() is the 8th of Nov 2018 
2018-11-01 

ceil 函数的工作方式类似,但不是向下取整,而是向上取整。至于 round 函数,它将向上或向下取整,取决于哪个值更接*:

julia> round(today(), Month) 
2018-11-01 # today is the 11th so beginning of month is closer 

julia> round(today() + Day(10), Month) 
2018-12-01 # end of month is closer 

在一些边缘情况下,舍入可能会表现得不可预期——更多细节请查看官方文档docs.julialang.org/en/v1/stdlib/Dates/index.html#Rounding-1

添加对时区的支持

如前所述,默认情况下,Julia 的日期/时间对象以本地时间操作,完全忽略时区。然而,我们可以很容易地使用TimeZones包将它们扩展为时区感知。请按照常规方式安装它:

julia> using Pkg
pkg> add TimeZones

一旦我们通知编译器我们将使用TimeZones,大量的时区相关功能将变得触手可及。

我们可以从探索可用的时区开始:

julia> timezone_names() 
439-element Array{AbstractString,1}: 
 "Africa/Abidjan" 
 "Africa/Accra" 
# output truncated 

让我们为Amsterdam创建一个时区对象:

julia> amstz = TimeZone("Europe/Amsterdam") 
Europe/Amsterdam (UTC+1/UTC+2) 

在 Julia 中,TimeZone是一个抽象类型,它代表有关特定时区的信息,这意味着它不能被实例化——我们不能创建这种类型的对象。相反,它的两个子类型之一将被自动使用——VariableTimeZoneFixedTimeZoneVariableTimeZone代表一个时区,其偏移量根据年份的时间而变化——为了考虑夏令时/夏令时。FixedTimeZone有一个不变的偏移量。

Europe/Amsterdam (UTC+1/UTC+2)是一个这样的VariableTimeZone。这由圆括号内的信息表示,表明这个时区的两个偏移量。检查类型将确认这一点:

julia> typeof(amstz) 
TimeZones.VariableTimeZone 

不改变其偏移量的时区是FixedTimeZone的实例。这样的例子有UTCGMT

julia> typeof(TimeZone("GMT")) 
TimeZones.FixedTimeZone 

TimeZones包还提供了一个特殊的字符串字面量,tz"..."。它提供了与TimeZone(...)相同的功能,但输入更少:

julia> tz"Europe/Bucharest" 
Europe/Bucharest (UTC+2/UTC+3) 

拥有这个知识,我们现在可以创建对时区有感知的日期/时间值。这些值以ZonedDateTime对象的形式出现,正如其名所示,代表DateTimeTimeZone的混合:

# 8 PM, Christmas Day in Vienna, 2018 
julia> ZonedDateTime(DateTime(2018, 12, 25, 20), tz"Europe/Vienna") 
2018-12-25T20:00:00+01:00 

这可以通过省略对DateTime的显式调用而写得更加简洁:

julia> ZonedDateTime(2018, 12, 25, 20, tz"Europe/Vienna") 
2018-12-25T20:00:00+01:00 

TimeZones模块还提供了一系列实用方法。首先,我们可以使用名为localzone的函数检索本地时区:

julia> localzone() 
Europe/Madrid (UTC+1/UTC+2) 

我住在巴塞罗那,所以这是我当前所在的时区——你的输出将对应于你的实际时区。

nowtoday提供了两个扩展方法——分别是now(::TimeZone)today(::TimeZone)

julia> now() 
2018-11-08T22:32:59.336 

julia> now(tz"Europe/Moscow") 
2018-11-09T00:33:23.138+03:00 

julia> today() 
2018-11-08 

julia> today(tz"Europe/Moscow") 
2018-11-09 

而不是使用today(::TimeZone),可以使用另一个函数todayat,它接受两个参数——一天中的时间作为一个Time对象和一个TimeZone

julia> todayat(Time(22, 30), tz"Europe/Moscow") 
2018-11-09T22:30:00+03:00 

这次我们得到了晚上 10:30 的莫斯科时间。

转换时区

我们最基本想要做的事情之一是将一个DateTime从一个时区转换到另一个时区。这可以通过astimezone函数直接完成:

julia> xmas_day = ZonedDateTime(2018, 12, 25, 20, tz"Europe/Vienna") 
2018-12-25T20:00:00+01:00 

julia> astimezone(xmas_day, tz"Australia/Sydney") 
2018-12-26T06:00:00+11:00 

当你在维也纳庆祝*安夜,晚上 8 点时,在澳大利亚的悉尼,已经是第二天早上 6 点了。

解析日期字符串

我们已经看到了如何使用 Julia 的Dates API 解析日期和日期/时间字符串。TimeZones包将这一功能推进了一步,允许我们解析包含时区的日期/时间字符串:

julia> ZonedDateTime("2018-12-25T20:00:00+01:00", "yyyy-mm-ddTHH:MM:SSzzzz") 
2018-12-25T20:00:00+01:00 

ZonedDateTime 时间段算术

你会很高兴听到,时区感知对象的算术运算与常规的TimeType对应物的工作方式完全一样。然而,在处理开始于一个偏移量(例如冬季)并结束于另一个偏移量(比如说夏季)的时间段时,你必须格外小心。例如,让我们看看当我们玩弄欧洲切换到夏令时的时间点周围的时刻会发生什么。

三月的最后一个星期日会进行时钟调整。花一分钟时间,试着找出 2019 年三月最后一个星期日的日期。

这里是我的解决方案:

julia> last_day_of_winter = tonext(today()) do d 
           dayofweek(d) == Sunday && 
           month(d) == March && 
           dayofmonth(d) > dayofmonth(lastdayofmonth(d) - Day(7)) 
       end 
2019-03-31 

现在,让我们给它一个时区——比如说伦敦的:

london_time = ZonedDateTime(DateTime(last_day_of_winter), tz"Europe/London") 
2019-03-31T00:00:00+00:00 

向这个日期加一天将导致时区发生变化:

julia> next_day = london_time + Day(1) 
2019-04-01T00:00:00+01:00 

但如果我们现在移除相当于一天的时间,但以小时计算呢?我们应该再次得到london_time的值,对吧?看看:

julia> next_day - Hour(24) 
2019-03-30T23:00:00+00:00 

哎呀,不是这样的!减去24小时实际上使我们比london_time提前了一个小时。这是因为偏移量的变化(切换到夏令时)实际上导致 25 日凌晨 2 点的整整一个小时被跳过,使得那天只有 23 个小时长。

时区感知日期范围

在处理时区时,还有另一件重要的事情需要记住,那就是日期范围。如果你的范围开始项在一个时区,但结束项在另一个时区,则对应于结束项的结果值将默默地转换为开始项的时区。一个例子将使这一点变得清晰:

julia> interval = ZonedDateTime(2019, 8, 1, tz"Europe/London"):Hour(1):ZonedDateTime(2019, 8, 2, tz"Australia/Sydney") 
2019-08-01T00:00:00+01:00:1 hour:2019-08-02T00:00:00+10:00 

julia> collect(interval) 
16-element Array{TimeZones.ZonedDateTime,1}: 
2019-08-01T00:00:00+01:00 
# output truncated 
2019-08-01T15:00:00+01:00 

数组中的最后一个项目,2018-08-01T15:00:00+01:00,代表与区间结束项相同的时刻,2018-08-02T00:00:00+10:00——但它使用的是伦敦时区,而不是悉尼的:

julia> astimezone(ZonedDateTime("2019-08-01T00:00:00+01:00", "yyyy-mm-ddTHH:MM:SSzzzz"), tz"Australia/Sydney" ) 
2019-08-01T09:00:00+10:00 

时间相同,但时区不同。

Julia 中的时间序列数据

时间序列是一组通过重复测量在一段时间内获得的对定义良好的数据项的观察。这个定量观察的集合是有序的,使我们能够理解其底层结构。此类数据的例子包括公司股票的每日收盘价、零售商的季度销售额、对个人血糖水*的连续监测,或每小时空气温度。

Julia 的包生态系统通过TimeSeries包提供了强大的时间序列处理功能。该包提供了一个广泛的 API,涵盖了从读取和写入具有时间数据的 CSV 文件,到过滤和分割时间序列,再到数学和逻辑运算符,以及绘图的全套任务。让我们将其添加到我们的工具箱中:

julia> using Pkg 
pkg> add TimeSeries  

现在,让我们获取一些时间序列数据。我们能够做的最简单的事情就是使用MarketData包,它为研究和测试提供开源的金融数据,并且与TimeSeries完美兼容。一旦您以常规方式(pkg> add MarketData)安装它,该模块将暴露一系列变量,对应不同的数据集。其中一些是小型测试数据库,称为clohclohlcv等。例如,ohcl数据集包含从 2000 年 1 月 3 日到 2001 年 12 月 31 日的 500 行市场数据——每一行包含一个Date,以及OpenHighLowClose值。以下是它的样子:

julia> using MarketData 
julia> MarketData.ohlc 
500x4 TimeSeries.TimeArray{Float64,2,Date,Array{Float64,2}} 2000-01-03 to 2001-12-31 

您可以看到它属于TimeArray类型,并且跨越了我刚才提到的时段:

MarketData模块还公开了三家主要公司的更大历史价格和成交量数据——苹果(AAPL)、波音(BA)和卡特彼勒(CAT)。

使用 Plots 和 PyPlot 快速查看我们的数据

正如古老的谚语所说,“一图胜千言”,让我们通过绘图来快速了解我们的数据。这是一个介绍最好的 Julia 数据可视化包——Plots的好机会。与之前我们使用的Gadfly不同,Plots采用不同的方法——它是一个覆盖多个不同绘图库的接口。基本上,它就像中间件,为其他绘图包(称为后端)提供了一个通用、统一的 API。在 Julia 中,不同的绘图包有不同的功能和优势——根据用户的特定需求,他们可能被迫学习另一个库,更改代码,等等,以便可以互换使用不同的绘图包。Plots通过一个统一的 API 和一种简单的机制来解决此问题,该机制允许动态交换后端。

Plots包支持以下后端——PyPlotPlotlyPlotlyJSGRUnicodePlotsPGFPlotsInspectDRHDFS5。您应该使用哪一个?这取决于实际用例,但根据Plots作者的表述——GR 用于速度,Plotly(JS)用于交互性,否则使用 PyPlot

请阅读官方文档docs.juliaplots.org/latest/backends/,以了解每个后端的优势和劣势。

我们将使用PyPlot,这是对同名流行 Python 包的 Julia 包装器。让我们安装PlotsPyPlot。运行pkg> add Plots应该是直接的。接下来,pkg> add PyPlot将更加复杂。由于PyPlot使用PyCall.jl来调用 Python 代码,根据您当前的 Julia 安装,Pkg可能还需要安装miniconda Python 发行版。因此,可能需要几分钟。

要开始使用PlotsPyPlot,请确保您运行以下命令:

julia> using Plots 
julia> pyplot() 
Plots.PyPlotBackend() 

pyplot函数配置PyPlot后端,以便Plots使用。

在安装PyPlot后,尝试运行pyplot()时,你可能会遇到错误。如果建议这样做,请遵循包提供的说明并重新启动 Julia REPL。

我们现在可以开始了!该模块公开了plot函数,在最简单的情况下,可以通过两个值集合来调用,对应于 x 和 y 坐标:

julia> plot(1:10, rand(10))

你应该会看到一个新窗口中渲染的图表——我的看起来像这样,但由于我们正在可视化随机值,你的可能会有所不同:

图片

这是一个由PyPlot渲染的十个随机值的图表。

这些图表的一个有趣之处在于,它们可以使用plot!函数进行修改。例如,我们可以通过绘制一个矩阵来向其中添加两条线:

julia> plot!(rand(10, 2)) 

结果输出如下:

图片

我们可以使用属性来增强图表。它们允许我们添加标签、标题,以及样式化可视化等等。例如,以下是使用额外属性渲染我们之前的图表的方法:

julia> plot(1:10, rand(10,3), title="Three Lines",label=["First" "2nd" "Third Line"],lw=3) # lw stands for line width 

输出如下:

图片

API 还公开了在渲染后修改图表的函数:

julia> xlabel!("Beautiful lines") 
julia> ylabel!("Small lines") 

输出如下:

图片

回到我们的市场数据,你可能会很高兴地听到TimeSeries提供了与Plots的即插即用集成。我们只需要运行以下命令:

julia> plot(MarketData.ohlc) 

这是我们得到的结果:

图片

我们可以看到市场一直在增长,在 2000 年 3 月达到顶峰,然后突然下降到大约 50-60。它在那里停留了几个月,然后在 9 月底再次下降,一直保持在 30 以下,直到 2001 年底。四个值,Open(开盘价)、Close(收盘价)、High(最高价)和Low(最低价)似乎高度相关。我们可以单独绘制它们:

julia> plot(MarketData.ohlc[:High]) 

我们得到以下结果:

图片

我们可以这样添加额外的值:

julia> plot!(MarketData.ohlc[:Low]) 

输出如下:

图片

通过可视化高值与低值,我们可以看到在市场崩溃前的时期有更高的变化。

TimeArray类型

那么,这个TimeArray到底是什么呢?你可能想知道?它看起来像是一个有趣的生物,因为我们可以用方括号和列名来索引它。我们可以使用fieldnames函数来查看它暴露了哪些属性:

julia> fieldnames(typeof(MarketData.ohlc)) 
(:timestamp, :values, :colnames, :meta) 

事实上,一个TimeArray是一个复合类型——在 Julia 中我们称之为struct,它有四个字段。

timestamp字段代表一个时间值向量——它们持有时间序列的时间坐标。因此,如果我们查看我们的TimeArray对象,我们会在第一行看到这个:

julia> MarketData.ohlc |> head 
6×4 TimeArray{Float64,2,Date,Array{Float64,2}} 2000-01-03 to 2000-01-10  

它看起来像这样:

图片

在这个输出中,2000-01-03timestamp数组中的第一个值。我们可以使用timestamp获取器来访问数组:

julia> timestamp(MarketData.ohlc) 
500-element Array{Date,1}: 
 2000-01-03 
 2000-01-04 
 2000-01-05 
# output truncated 

julia> timestamp(MarketData.ohlc)[1]  
2000-01-03 

根据数据集中的实际信息,其类型可以是 Date(如我们的情况),TimeDateTime——TimeType 的任何子类型。

在构建 TimeArray 时,你必须小心,因为 timestamp 数据必须排序——否则,构造函数将出错。

类似地,与 timestamp 字段一样,你可能可以猜到 values 属性的内容。它包含时间序列的数值数据:

julia> values(MarketData.ohlc) 
500×4 Array{Float64,2} 

输出如下:

图片

显然,values 数组的行数必须与 timestamp 集合的长度相匹配。不那么明显的是,values 数组内部的所有值必须属于同一类型。

因此,TimeArray 中的每一行都由 timestamp 集合中的一个项目以及 values 数组中的对应行组成:

图片

colnames 函数返回值字段中每个列的列名数组。它们作为符号返回:

julia> colnames(MarketData.ohlc)  
4-element Array{Symbol,1}: 
 :Open 
 :High 
 :Low 
 :Close  

这里的唯一硬性约束是 colnames 向量中的项目数必须与 values 集合中的列数相匹配。由于 TimeArrays 可以通过列名索引,colnames 向量中的重复字符串将由构造函数自动修改。每个后续的重复名称将附加一个 n,其中 n1 开始。

如果你对列名不满意,可以使用 rename 方法进行更改,传入 TimeArray 对象和列名符号数组:

julia> rename(MarketData.ohlc, [:Opening, :Maximum, :Minimum, :Closing]) 

最后,meta 字段应该用于将元信息附加到对象上。默认情况下,它是空的,可以根据需要由程序员设置。

索引 TimeArray 对象

TimeSeries 库公开了一个强大的 API,用于访问结构化为 TimeArray 数据的信息。我们已经看到,我们可以通过按列名索引来访问单个列:

julia> MarketData.ohlc[:High] 
500×1 TimeArray{Float64,1,Date,Array{Float64,1}} 2000-01-03 to 2001-12-31 

这就得到了以下结果:

图片

我们甚至可以使用列的组合:

julia> MarketData.ohlc[:High, :Low] 

输出如下:

图片

我们还可以使用行 ID 和日期/时间(对应于 timestamp 值)来索引数组。让我们尝试获取 Close 值最高的行。首先,让我们找到它:

julia> maximum(values(MarketData.ohlc[:Close])) 
144.19 

最高收盘价为 144.19。请注意,按列名索引返回另一个 TimeArray 实例,因此要获取其底层数值,我们需要使用 values 函数。

现在我们可以找到其对应的索引。我们可以通过使用 findall 快速获取所有等于 144.19 的值的索引数组:

julia> findall(values(MarketData.ohlc[:Close]) .== 144.19) 
1-element Array{Int64,1}: 
 56 

那将是第 56 行。我们可以使用这些信息来索引时间序列:

julia> MarketData.ohlc[56] 
1×4 TimeArray{Float64,2,Date,Array{Float64,2}} 2000-03-22 to 2000-03-22 

输出如下:

图片

这是 2000 年 3 月 22 日。如果我们想查看其前后行,可以轻松做到:

julia> MarketData.ohlc[50:60] 
11×4 TimeArray{Float64,2,Date,Array{Float64,2}} 2000-03-14 to 2000-03-28 

这是生成的 TimeArray

图片

如果我们想要检查我们日期前后同一星期的值,使用范围索引支持步长参数。我们可以如下使用它:

julia> MarketData.ohlc[7:7:70] 

我们过滤每隔第七天,从第七行开始,一直到第七十行;也就是说,每个星期三,如Dates.dayname所示:

julia> dayname(timestamp(MarketData.ohlc)[56]) 
"Wednesday" 

如果我们想要检索所有的星期三,当然可以使用 end 关键字,例如 MarketData.ohlc[7:7:end]

假设我们对此满意,但还想获取更多关于我们日期的上下文信息。因此,我们想要所有星期三以及我们日期的前一天和后一天。我们也可以通过索引索引数组来实现这一点:

julia> MarketData.ohlc[[7:7:49; 54;55;56;57; 63:7:70]] 
13×4 TimeArray{Float64,2,Date,Array{Float64,2}} 2000-01-11 to 2000-04-11 

输出如下:

图片

这里,我们抽取 7 到 49 之间的每第七行,然后是第 54、55、56 和 57 行,然后是 63 到 70 之间的每第七行。

TimeArray 索引非常灵活,但请注意,行必须始终按日期排序。这就是为什么我们不能说,例如,[7:7:70; 54;55;56;57]——元素会顺序错乱。至于错误,包括重复行也会导致错误。

我们还可以使用日期/时间对象进行索引:

julia> MarketData.ohlc[Date(2000, 03, 22)] 
1×4 TimeArray{Float64,2,Date,Array{Float64,2}} 2000-03-22 to 2000-03-22 

这会产生以下结果:

图片

是的,我们也可以使用日期/时间范围:

julia> MarketData.ohlc[Date(2000, 03, 20):Day(1):Date(2000, 04,30)] 
29×4 TimeArray{Float64,2,Date,Array{Float64,2}} 2000-03-20 to 2000-04-28 

输出如下:

图片

使用其他日期范围步长同样有效:

julia> MarketData.ohlc[Date(2000, 03, 20):Dates.Week(1):Date(2000, 04,30)] 
6×4 TimeArray{Float64,2,Date,Array{Float64,2}} 2000-03-20 to 2000-04-24 
# output truncated 

结合多个索引也行得通:

julia> MarketData.ohlc[[Date(2000, 03, 20):Day(1):Date(2000, 04,30); Date(2000, 05, 01)]] 
30×4 TimeArray{Float64,2,Date,Array{Float64,2}} 2000-03-20 to 2000-05-01 

最后,我们可以组合任何我们想象得到的列和行:

julia> MarketData.ohlc[:High, :Low][Date(2000, 03, 20):Day(1):Date(2000, 03,25)] 
5×2 TimeArray{Float64,2,Date,Array{Float64,2}} 2000-03-20 to 2000-03-24 

这是结果:

图片

查询 TimeArray 对象

TimeSeries 模块提供了一个强大的、类似查询的 API,用于过滤时间序列数据。让我们看看它们中的每一个。

when() 方法

when 方法允许将 TimeArray 中的元素聚合到特定的时期。例如,我们可以使用这个函数以更简洁的方式选择数据集中的星期三:

julia> when(MarketData.ohlc[1:70], Dates.dayname, "Wednesday") 
14x4 TimeArray{Float64,2,Date,Array{Float64,2}} 2000-01-05 to 2000-04-05 
# output truncated 

我们不仅限于使用 Dates.dayname;我们还可以使用之前章节中提到的许多 Dates 函数——daydaynameweekmonthmonthnameyeardayofweekdayofweekofmonthdayofyearquarterofyeardayofquarter

julia> when(MarketData.ohlc, Dates.monthname, "August") 
46x4 TimeArray{Float64,2,Date,Array{Float64,2}} 2000-08-01 to 2001-08-31 
# output truncated 

from() 方法

这个函数从传递给方法的日期开始截断 TimeArray。与传递的日期对应的行包含在结果中:

julia> from(MarketData.ohlc, Date(2000, 3, 22)) 
445x4 TimeArray{Float64,2,Date,Array{Float64,2}} 2000-03-22 to 2001-12-31 

输出如下:

图片

to() 方法

to() 方法返回作为参数传递的日期及之前的所有行:

julia> to(MarketData.ohlc, Date(2000, 3, 22)) 
56x4 TimeArray{Float64,2,Date,Array{Float64,2}} 2000-01-03 to 2000-03-22 
# output truncated 

findall()findwhen() 方法

这个函数族测试一个条件,并返回条件为真的结果。唯一的区别是findall()返回一个包含行号的数组,而findwhen()返回一个日期/时间对象的向量。例如,如果我们想找到所有收盘价至少比开盘价高 10%的行,我们可以运行以下代码:

julia> findall(MarketData.ohlc[:Close] .>= MarketData.ohlc[:Open] .+ MarketData.ohlc[:Open] .* 0.1) 
7-element Array{Int64,1}: 
  55 
  74 
 119 
 254 
 260 
 271 
 302 

findwhen将产生类似的输出,但针对日期:

julia> findwhen(MarketData.ohlc[:Close] .>= MarketData.ohlc[:Open] .+ MarketData.ohlc[:Open] .* 0.1) 
7-element Array{Date,1}: 
 2000-03-21 
 2000-04-17 
 2000-06-21 
 2001-01-03
 2001-01-11 
 2001-01-29 
 2001-03-14 

操作时间序列对象

TimeSeries提供了一组简约但高效的方法来修改TimeArray对象。

merge()

首先,我们可以将两个TimeArrays的数据合并起来。merge方法使用时间戳作为连接列,并且默认执行内连接。但也可以执行左连接、右连接和外连接。让我们生成一些随机数据来实验。我们将从创建一个包含随机值的时间序列开始,这些值分布在从今天开始的整个星期内:

julia> d1 = today():Day(1):today() + Week(1) |> collect 
8-element Array{Date,1}: 
 2018-11-08 
 2018-11-09 
 2018-11-10 
 2018-11-11 
 2018-11-12 
 2018-11-13 
 2018-11-14 
 2018-11-15 

julia> t1 = TimeArray(d1, rand(length(d1)), [:V1]) 
8×1 TimeArray{Float64,1,Date,Array{Float64,1}} 2018-11-08 to 2018-11-15 

输出如下:

图片

接下来,我们将创建另一个跨越十天的时间序列对象:

julia> d2 = today():Day(1):today() + Day(10) |> collect 
11-element Array{Date,1}: 
 2018-11-08 
 2018-11-09 
 2018-11-10 
 2018-11-11 
 2018-11-12 
 2018-11-13 
 2018-11-14 
 2018-11-15 
 2018-11-16 
 2018-11-17 
 2018-11-18 

julia> t2 = TimeArray(d2, rand(length(d2)), [:V2]) 
11×1 TimeArray{Float64,1,Date,Array{Float64,1}} 2018-11-08 to 2018-11-18  

这将产生以下结果:

图片

因此,我们现在有两个TimeArray实例,t1t2t2对象包含t1中所有天的值以及额外三天。一个常规的(内连接)merge只会使用t1t2中都存在的时间戳的行:

julia> merge(t1, t2) 
8×2 TimeArray{Float64,2,Date,Array{Float64,2}} 2018-11-08 to 2018-11-15 

这是输出:

图片

正确,左连接、右连接和外连接将为不对应的行引入NaN值:

julia> merge(t1, t2, :right) 
11×2 TimeArray{Float64,2,Date,Array{Float64,2}} 2018-11-08 to 2018-11-18 

输出如下:

图片

vcat()方法

vcat()方法可以被认为是merge的对应方法。如果merge连接两个时间序列的列,vcat则合并它们的行。它最明显的用例是将来自多个文件的分割数据集的数据组合在一起。让我们看看它的实际应用:

julia> d3 = today() + Week(2):Day(1):today() + Week(3) |> collect  
8-element Array{Date,1}: 
 2018-11-22 
 2018-11-23 
 2018-11-24 
 2018-11-25 
 2018-11-26 
 2018-11-27
 2018-11-28 
 2018-11-29 

julia> t3 = TimeArray(d3, rand(length(d3)), [:V1]) 
8×1 TimeArray{Float64,1,Date,Array{Float64,1}} 2018-11-22 to 2018-11-29 

输出如下:

图片

我们创建了一个新的TimeArray,它覆盖了从今天开始的两周到三周的时间段:

julia> vcat(t1, t3) 
16×1 TimeArray{Float64,1,Date,Array{Float64,1}} 2018-11-08 to 2018-11-29 

这是生成的TimeArray

图片

生成的结果时间序列结合了t1t3的数据。

collapse()方法

此方法允许将数据压缩到更大的时间框架中,例如将每日数据转换为每周数据:

julia> january = TimeArray(Date(2018, 1, 1):Day(1):Date(2018, 1, 31) |> collect, rand(31), [:values]) 
31×1 TimeArray{Float64,1,Date,Array{Float64,1}} 2018-01-01 to 2018-01-31 

它产生以下输出:

图片

如果我们要collapse一月份的时间序列,我们需要决定如何处理被压缩的数据。这是通过传递函数参数来实现的。该方法的一般形式如下:

collapse(<time series>, <time function>, <time filtering function>, <value collapsing function>) 

例如,我们可以通过保留周期内的最后一天(<time filtering function>)并计算值的*均值(<value collapsing function>)来将一月份的数据collapse到一个周期间:

julia> using Statistics 
julia> collapse(january, week, last, mean) 
5×1 TimeArray{Float64,1,Date,Array{Float64,1}} 2018-01-07 to 2018-01-31 

输出结果如下:

<value collapsing function> 是可选的,如果没有提供,则将使用与时间戳对应的值:

julia> collapse(january, week, first) 
5×1 TimeArray{Float64,1,Date,Array{Float64,1}} 2018-01-01 to 2018-01-29 

这是我们得到的结果:

map() 方法

最后,map() 函数允许我们对时间序列中的每一行进行迭代,并对时间戳和值应用一个函数。我们可以轻松地将 january 时间序列的第一周推迟一年,如下所示:

julia> map(january[1:7]) do ts, values 
           ts += Year(1) 
           (ts, values) 
       end 
7×1 TimeArray{Float64,1,Date,Array{Float64,1}} 2019-01-01 to 2019-01-07 

输出结果如下:

关于 TimeSeries 还有更多要说的。但到目前为止,我们先到此为止。我们将在下一章回到 TimeArray,我们将用它来对欧盟的失业数据进行时间序列分析和预测。

摘要

在本章中,我们学习了在 Julia 中处理日期和时间的方法。该语言提供了一个强大且易于访问的 API,遵循 Julia 的整体哲学——你可以从简单开始,随着知识的增长而增强你的代码。因此,默认情况下,日期/时间对象使用本地时间,忽略像时区这样的复杂细节。然而,时区支持只需一个包即可实现。我们看到了如何通过使用 TimeZones 提供的功能来扩展 Julia 的 Dates API。

通过我们对时间数据的理解,我们又能向前迈出一大步,成为熟练的 Julia 程序员,并学习了时间序列和强大的 TimeArray。我们看到了如何使用 Plots 库来绘制时间序列,这是一个为 Julia 提供极其灵活绘图功能的库——实际上,它是一个中间件,为一系列可视化包提供了一个通用的 API,使我们能够根据需要交换后端。

在下一章中,我们将继续讨论时间序列,通过对欧盟的失业水*进行分析和预测来执行。在这个过程中,我们将了解时间序列分析最重要的模式——趋势、季节性和不规则性,并且我们将通过执行各种时间序列转换来扩展我们对 TimeSeries 的知识。

第十章:时间序列预测

在上一章中,我们学习了如何使用 Julia 处理日期和时间。这使我们能够理解时间序列数据的重要概念。现在,我们准备讨论另一个高度重要的数据科学主题——时间序列分析。

时间序列分析和预测代表了任何组织的关键战略和决定性组成部分,从理解销售高峰期到季节结束间隔和折扣,安排员工休假时间,预算,财政年度,产品发布周期,原材料需求增加,以及许多其他方面。理解和预测各种商业指标随时间的变化是商业活动的一个必要部分,无论我们是在谈论学校、价值数十亿美元的公司、酒店、超市还是政府。

然而,时间序列数据分析是数据科学中最复杂任务之一。时间事件的性质和特殊性导致了专用算法和方法的发展。

在本章中,我们将使用 Julia 研究时间序列分析和预测的基础知识。尽管 Julia 是一种相对较新的语言,但它已经具备了处理时间相关数据的好支持。在上一章中,我们学习了关于 Dates 模块和 TimeSeries 包的内容。在本章中,我们将更深入地研究并应用我们之前学到的知识。我们还将了解更多高级的 TimeSeries 方法以及一些其他用于处理时间数据的包。我们将涵盖以下主题:

  • 欧洲联盟(EU)失业数据的探索性数据分析

  • 趋势、周期、季节性和误差——时间序列的组成部分

  • 时间序列分解

  • 时间序列数据的*稳性、差分和自相关性

  • 学习应用简单的预测技术

技术要求

Julia 包生态系统正在持续发展中,并且每天都有新的包版本发布。大多数时候,这是一个好消息,因为新版本带来了新功能和错误修复。然而,由于许多包仍在测试版(版本 0.x)中,任何新版本都可能引入破坏性更改。因此,书中展示的代码可能无法正常工作。为了确保您的代码能够产生与书中描述相同的结果,建议使用相同的包版本。以下是本章使用的外部包及其具体版本:

CSV@v0.4.3
DataFrames@v0.15.2
IJulia@v1.14.1
Plots@v0.22.0
TimeSeries@v0.14.0

为了安装特定版本的包,您需要运行:

pkg> add PackageName@vX.Y.Z 

例如:

pkg> add IJulia@v1.14.1

或者,您也可以通过下载章节提供的 Project.toml 文件并使用 pkg> 实例化来安装所有使用的包:

julia> download("https://raw.githubusercontent.com/PacktPublishing/Julia-Programming-Projects/master/Chapter10/Project.toml", "Project.toml")
pkg> activate . 
pkg> instantiate

快速查看我们的数据

在本章中,我们将使用由欧盟统计局提供的真实数据。欧盟统计局在其网站上拥有丰富的数据库。为了我们的学习项目,我们将查看失业数据——在经历了长期的经济衰退后,欧盟的经济正在增长,这些统计数据应该非常有趣。各种欧盟就业和失业数据可以从 ec.europa.eu/eurostat/web/lfs/data/database 下载。我们将使用 按性别和年龄划分的失业率——月*均 数据集。

您不需要下载这个,因为本章的支持文件中提供了一个结构更好的数据集。然而,如果您好奇并想看看,您可以从就业和失业(劳动力调查)类别 | LFS 主要指标子类别 | 失业 - LFS 调整系列文件夹下获取原始数据。

我还通过使用 千人 作为计量单位(默认是 活跃人口的百分比)和未调整的数据(既不是季节性的,也不是日历的)来定制了数据。我还只保留了欧盟的数据(没有单个国家)。最后,我包括了 2005 年 1 月到 2017 年 12 月的所有数据。您可以在数据探索器中进行所有这些调整,然后下载表格作为 TSV 文件。至于 TSV 格式,我选择了以下选项:

在欧盟统计局数据探索工具中可视化,数据看起来是这样的:

我们可以看到第一列是地理区域列表,其余列是按月度的失业数据。这个数据集的结构与我们所需的不同。首先,TimeSeries 需要矩阵转置(即,日期应成为行而不是列)。此外,日期以非标准方式格式化,例如,2017M01 表示 2017 年 1 月。最后,数字以字符串格式表示,千位分隔符用空格表示。您可以从本章的支持文件中下载这个原始数据文件,这些文件托管在 github.com/PacktPublishing/Julia-Programming-Projects/blob/master/Chapter10/data/une_rt_m_1.tsv

在处理真实数据时,这类问题很常见——关于结构和格式的标准差异使得数据清洗和转换成为任何数据科学项目的关键第一步,通常也是一个耗时的工作。为了简洁起见,我已经准备了一个简化后的数据集,它已经被转换,可以无缝地与 TimeSeries 一起使用,您可以从本章的支持文件中下载。

数据处理

如果你想跟上来,以下是我是如何使用 Julia 处理原始数据的:

# load the raw data file as a DataFrame 
julia> using CSV, DataFrames 
julia> df = CSV.read("une_rt_m_1.tsv", header = true, delim = '\t') 
1×157 DataFrames.DataFrame. Omitted printing of 148 columns 

这是在 Jupyter Notebook 中的样子:

图片

在下一步中,我们将通过选择一个由 1 行和 2 列组成的DataFrame并将其转换为Array来提取值:

julia> values = convert(Array, df[1, 2:end]) 1×156 Array{Union{Missing, String},2}: "21 974" "22 303" "22 085" "21 036" "20 849" ... # output omitted 

现在,我们可以解析之前提取的字符串值并将它们转换为整数。新的整数值也存储在一个向量中:

julia> values = map(x -> parse(Int, replace(x, " "=>"")), values)[:] 
156-element Array{Int64,1}: 
 21974 
 22303 
 22085 
 21036 
# output omitted 

太好了——我们的值准备好了!现在我们可以专注于头部。我们的目标是提取标签中包含的日期信息。作为第一步,我们将列名拉入一个向量,如下所示:

julia> dates = names(df)[2:end] 
156-element Array{Symbol,1}: 
 Symbol("2005M01") 
 Symbol("2005M02") 
 Symbol("2005M03") 
 Symbol("2005M04") 
 Symbol("2005M05") 
# output omitted 

现在,让我们转换符号,使它们更接*我们需要的标准日期格式。我们将用连字符替换"M",在这个过程中,我们将符号转换为String,因为替换操作不适用于符号:

julia> dates = map(x -> replace(string(x), "M"=>"-"), dates) 
156-element Array{String,1}: 
 "2005-01" 
 "2005-02" 
 "2005-03" 
 "2005-04" 
 "2005-05" 
# output omitted 

太棒了!现在,我们可以定义一个与我们的字符串匹配的DateFormat——年份加连字符加月份,月份为一个带前导零的数字。我们将使用它将字符串转换为正确的日期对象:

julia> using Dates 
julia> dateformat = DateFormat("y-m") 
dateformat"y-m" 

julia> dates = map(x -> Date(x, dateformat), dates) 
156-element Array{Date,1}: 
 2005-01-01 
 2005-02-01 
 2005-03-01 
 2005-04-01 
 2005-05-01 
# output omitted 

我们越来越接*了!为了安全地将数据持久化到文件中,我创建了一个新的DataFrame,这次使用了正确的日期和原始值,如下所示:

# store the extracted data in a new DataFrame 
julia> df2 = DataFrame(Dates = dates, Values = values) 
156×2 DataFrames.DataFrame 
│ Row │ Dates      │ Values │ 
│ 1   │ 2005-01-01 │ 21974  │ 
│ 2   │ 2005-02-01 │ 22303  │ 
│ 3   │ 2005-03-01 │ 22085  │ 
│ 4   │ 2005-04-01 │ 21036  │ 
│ 5   │ 2005-05-01 │ 20849  │ 
# output omitted 

我们可以使用CSV.write通过以下代码将我们的数据快照存储到文件中:

# write DataFrame to file 
julia> CSV.write("UE-unemployment.tsv", df2) 

现在,我们可以从 TSV 文件中加载数据作为TimeArray

julia> using TimeSeries
julia> unemployment_data = readtimearray("UE-unemployment.tsv")
156x1 TimeArray{Float64,2,Date,Array{Float64,2}} 2005-01-01 to 2017-12-01

如果您想直接从DataFrame转换为TimeSeries数据,而不必加载 TSV 文件,您可以使用IterableTables包。IterableTables为 Julia 中不同表格类型之间提供了丰富的转换方法。您可以在包的 README 中了解更多信息,网址为github.com/davidanthoff/IterableTables.jl

我们的时间序列数据已正确加载——从 2005 年 1 月到 2017 年 12 月共有 156 个条目。它看起来像这样:

julia> TimeSeries.head(unemployment_data, 10) 
10x1 TimeArray{Float64,2,Date,Array{Float64,2}} 2005-01-01 to 2005-10-01 

输出如下:

图片

我们不得不使用头函数的完全限定名称,TimeSeries.head,因为TimeSeriesDataFrames都导出了head方法,并且这两个包都被加载到当前作用域中。

如果没有模块的名称就尝试调用头函数,将会导致错误:

julia> head(unemployment_data, 10) 
WARNING: both TimeSeries and DataFrames export "head"; uses of it in module Main must be qualified 
ERROR: UndefVarError: head not defined 

要快速了解我们的数据,最好的方法就是绘制一个图表。我们将使用带有PyPlot后端的Plots包——我们在第九章[11df7c94-2e9a-4cc5-aba1-b9c9c93800a0.xhtml] 处理日期、时间和时间序列 中安装了它们:

julia> using Plots 
julia> pyplot() 

PyPlot后端有复杂的依赖关系,所以如果您在执行指示的代码时遇到问题,请按照警告和错误中提供的说明操作。

例如,在某个时候,我不得不手动安装两个额外的包:

julia> using Pkg 
julia> pkg"add PyCall LaTeXStrings"

现在,我们可以绘制失业率图表:

julia> plot(unemployment_data) 

Julia 将渲染以下图表:

图片

我们可以很容易地看出,自 2005 年以来,失业人数一直在稳步下降,在 2008 年下半年达到了历史最低点。从那时起,在几个月的时间里,失业人数激增至自 2005 年以来的未知水*。这是经济衰退袭击欧盟经济的那一刻。从那时起,失业人数继续增长,直到它们最终在 2013 年初达到峰值。2013 年 2 月,失业人数达到了最高点,此后欧洲经济开始复苏,失业人数迅速下降,接*经济衰退前的水*。

理解时间序列成分

时间序列有三个关键成分对于理解与时间相关的数据至关重要。它们是趋势季节性噪声。让我们在我们欧盟失业数据的背景下看看每一个。

趋势

趋势可以被定义为时间序列数据的长期趋势——即在一段时间内,*均值倾向于增加或减少。观察我们的图表,我们可以识别出三个不同的趋势:

图片

从 2005 年到 2008 年呈下降趋势(年度失业人数减少);从 2008 年开始上升,一直持续到 2013 年(失业率*均上升);再次,从 2013 年开始,一直持续到 2017 年底(失业人数持续减少)。

季节性

季节性是指与日历时间相关的规律性高低波动模式;也就是说,它直接受到季节、季度、月份等因素的影响。例如,考虑一个城市的电力消耗——我们可能会在夏季由于空调使用而看到消费量的增加,在冬季由于需要供暖而增加。以类似的方式,通过观察海滨酒店,我们会看到夏季预订量显著增加,随后在冬季减少。

因此,季节性产生的效果在时间、方向和幅度上相对稳定。最常见的日历相关影响是自然条件(天气)、商业和行政程序(财政年度)以及社会和文化行为(由于国家和宗教节日而出现的银行假日,如圣诞节、情人节等)。它还包括由日历事件引起的效果,这些事件是周期性的,但在日期上不是固定的(例如,复活节,每年的日期都落在某个星期日,但实际日期会变化)。

失业数据受到强烈的季节性影响——在夏季月份,更多的人被雇佣。这些似乎都是临时工作,可能是在旅游业,帮助酒店和餐馆应对假日游客的涌入——但也可能在办公室和零售行业,以填补常规员工的休假时间。我们可以在我们的图表中清楚地识别这一点——夏季月份是一年中的最低失业率,数值在秋季又开始上升:

图片

在年中三个夏季月份,失业率达到了最低水*。一旦季节性高峰过去,失业率再次急剧上升。

随机噪声

分析时间序列数据时的默认假设是我们能够识别一个潜在的规律(由其趋势和季节性成分定义)。然而,当数据中存在这种系统性模式时(例如,一些时间序列数据是完全随机的,例如地震发生频率),它也会伴随着方差——数据中的波动被归类为随机噪声、错误或不规则性。这使得识别模式变得更加困难,因此,数据科学家将使用某种形式的噪声过滤。

换句话说,这个不规则成分是在计算并移除季节性和趋势成分之后剩下的部分。它们是短期波动,既不系统也不可预测。

循环性

循环性在某种程度上与季节性相似,因此这两个经常被混淆。然而,它们是两件不同的事情,区分它们很重要。循环周期代表更长的时间段,我们可以在这段时间内识别数据中的重复模式(增长或下降的时期),并且不能通过日历模式来解释。它们通常更大,跨越几年,并且不与日历事件重叠。这种循环元素可以由产品发布周期(汽车型号的发布,或操作系统的全新版本,或笔记本电脑系列的升级)、选举周期(政府预算或与政府签订合同的公司)等引入。

时间序列分解

因此,我们可以这样说,时间序列中的任何值都可以通过我们之前讨论的四个成分的函数来表示——趋势、季节性、误差和循环。这四个成分之间的关系可以是加法乘法

当季节性变化在时间上保持大致相同的时候,使用加法模型。趋势可能是上升或下降,但季节性保持更多或更少相同。此类数据的图表看起来将非常类似于这个:

图片

如果我们在年最高值和年最低值之间画两条想象中的线,这些线将几乎是*行的。

对于加法时间序列模型,四个成分相加产生序列中的值。因此,时间序列 Y 可以分解为 Y = 趋势 + 周期 + 季节性 + 噪声

应该使用乘法模型来处理季节性变异性随时间增加的时间序列。例如,典型的乘法时间序列由 1949 年 1 月至 1960 年 1 月之间的国际航空旅客数据表示:

图片

我们可以看到季节性模式的变异性与时间序列的水*相关:乘客越多,变异性越高。一个乘法时间序列 Y 可以表示为 Y = 趋势 * 周期 * 季节性 * 噪声

作为旁注,我们可以通过转换数据直到它在时间上变得稳定,例如通过对数变换,将乘法模型转换为加法模型——Y = 趋势 * 周期 * 季节性 * 噪声 等价于 log Y = log 趋势 + log 周期 + log 季节性 + log 噪声

将时间序列分解为其成分是时间数据分析中广泛使用的技术。这被称为时间序列分解,它也是时间序列预测的基础。

解释数据——是采用加法方法还是乘法方法?

这是问题——两种方法中哪一种更好地解释了我们的数据?回答这个问题的方法之一是查看周期值,看是否有显著变化。由于我们处理的是年度周期,让我们按照以下方式提取并绘制年度年度值:

julia> plot() 
julia> for y in 2005:2017 
           TimeSeries.values(when(unemployment_data, year, y))[:] |> plot! 
           gui() 
       end 

首先,我们绘制一个空图。然后,我们遍历从 2005 年到 2017 年的年份范围,然后我们使用TimeSeries.when方法通过年份过滤我们的数据。我们通过使用plot!函数提取结果TimeArray值并将它们附加到图上。然而,这还不够——我们还需要调用Plots.gui方法来实际渲染更新的图。根据官方文档,这是一个非常重要的点:

"只有在返回时(分号将抑制返回),或者显式地使用display(plt)gui(),或者在您的绘图命令中添加show = true时,才会显示图形。"

您可以在docs.juliaplots.org/latest/output/了解更多关于输出图形的信息。

这是我们的结果:

图片

我们可以看到,每年都有一致的年度变化,这意味着我们应该使用乘法模型。

使用plots来直观地观察成分是识别时间序列中模式的一种常见方法。在我们的例子中,很容易看出存在趋势和季节性。此外,我们可以推断出数据没有表现出任何周期性模式。

记住,乘法模型认为 Y = 趋势 * 循环 * 季节性 * 噪声。我们可以将其简写为 Y = TCSN。由于我们刚刚确定我们的数据不呈现任何循环,我们将省略循环成分,因此 Y = TSN

提取趋势

分解时间序列的第一步是提取趋势成分。计算趋势的一个广泛使用的技术被称为 *滑。正如其名所示,它通过去除噪声和模糊季节性来 *滑 值,以便我们可以识别趋势。

执行*滑的一种方法是通过移动*均。在金融应用中,简单移动*均是前 n 个数据点的未加权*均值。这就像在我们的时间序列上应用一个移动窗口,并使用可见数据进行计算。然后,我们将窗口滑动一个位置并重复计算。为了*滑季节性数据,窗口的大小应该是季节期的长度——在我们的案例中,是 12 个月。因此,为了对我们的数据进行简单移动*均*滑,我们首先取前 12 个月份(2005 年),求和这些值,然后除以 12 以得到它们的*均值。然后,我们将窗口滑动一个月并重复计算。结果,我们消除了季节性成分的影响,并抵消了噪声的影响。

TimeSeries 包提供了一系列 apply 方法,用于实现时间序列数据的常见转换。其中之一是 moving 方法,它可以用来计算序列的移动*均。让我们计算 12 个月间隔的移动*均,以*滑季节性成分:

julia> using Statistics 
julia> moving_avg = moving(mean, unemployment_data, 12) 
145×1 TimeArray{Float64,2,Date,Array{Float64,2}} 2005-12-01 to 2017-12-01 

结果如下:

如我们所见,结果是包含我们原始时间序列 12 个月份*均值的新时间序列。原始序列的前 12 个值被这个操作消耗,因此我们的新序列从 2005 年 12 月开始。如果您希望保留初始值,moving 函数接受一个额外的关键字参数,padding。默认情况下,paddingfalse,但如果设置为 true,则消耗的时间戳将被保留,其值将被设置为 NaN

 julia> moving(mean, unemployment_data, 12, padding = true)
 156×1 TimeArray{Float64,2,Date,Array{Float64,2}} 2005-01-01 to 2017-12-01

这将产生以下输出:

在原始数据上方绘制*滑值可以指示趋势:

julia> plot(unemployment_data) 
julia> plot!(moving_avg) 

这是我们的图形:

第一次调用 plot 方法渲染原始欧盟失业率数据,而后续调用 plot! 方法会修改图形,叠加与趋势相对应的移动*均线。

计算季节性

现在我们已经提取了趋势,我们可以将其从初始时间序列中移除。这是通过除法完成的。我们将剩下季节性和噪声成分的乘积。因此,SN = Y/T

要计算 TimeArray 对象之间的分数,我们将使用逐元素除法运算符,./

julia> sn = unemployment_data ./ moving_avg 
145×1 TimeArray{Float64,2,Date,Array{Float64,2}} 2005-12-01 to 2017-12-01  

我们将得到以下结果:

绘制结果 TimeArray 将会给我们一个更清晰的季节性和噪声成分的乘积的图像:

julia> plot(sn) 

这导致了以下图表:

下一步是计算相同月份这些值的年度总和。也就是说,我们将计算所有年份 1 月份的所有月份的值;然后,我们将对 2 月、3 月做同样的处理。我们将得到每个日历月份所有年份的*均值。这将导致噪声的最小化:

julia> month_avg = Float64[] 
0-element Array{Float64,1} 

julia> for m in 1:12 
            md = when(sn, month, m) 
            push!(month_avg, mean(TimeSeries.values(md)[:]))  
       end 

首先,我们实例化一个 Float64 类型的 Vector。然后,我们在 112 的范围内迭代,这代表月份。在循环内部,我们使用 when 方法来过滤当前迭代月份的值(所有年份的 1 月值,然后是所有年份的 2 月值,然后是 3 月,依此类推),然后将这些值的*均值推入 month_avg 数组。循环结束时,我们将这些值收集到 month_avg 中,其中第一个是所有年份 1 月份的*均值,第二个是 2 月份,然后是 3 月,依此类推。它看起来会是这样:

julia> month_avg 
12-element Array{Float64,1}: 
 1.0376512639850295 
 1.0466377033754193 
 1.0301198608484736 
 1.0014842494206564 
 0.9830320492870818 
 0.9705256323692862 
 0.9630153389575429 
 0.9634443756458616 
 0.9763782494700372 
 0.9893785521401298 
 0.9987100016253194 
 0.9913489915307253 

理论上,这些值应该加起来是 12。实际上,这种情况并不发生(尽管我们非常接*)。我们可以很容易地使用 sum 函数来求出数组的所有元素的总和:

julia> s = sum(month_avg) 
11.951726268655563 

作为结果,我们需要将*均值归一化,以便它们确实加起来是 12。这是通过将每个季节因子乘以 12,然后除以它们的总和来实现的:

julia> norm_month_avg = map(m -> 12m/s, month_avg) 

我们使用了 map 函数来遍历 month_avg 中的每个项目作为 m,并应用了一个匿名函数,使得 m = 12m/s

12-element Array{Float64,1}: 
 1.0418423989910408 
 1.0508651351431808 
 1.0342805760704734 
 1.0055293037095092 
 0.9870025740450584 
 0.9744456429674838 
 0.9669050150351592 
 0.9673357846281114 
 0.9803218991358666 
 0.993374710799588  
 1.0027438505627655 
 0.9953531089117633 

让我们再次检查总和:

julia> sum(norm_month_avg) 
12.0 

完美!

现在我们已经计算了月度季节因子,我们可以通过除以季节因子来对原始时间序列进行季节调整。这样,我们将得到余数,它代表了趋势和噪声的乘积—Y/S = TN。在 Julia 中计算这个值,我们必须将 unemployment_data 的每个值除以相应的月度季节因子。

为了保持整洁,让我们将原始时间序列复制到一个不同的对象中:

julia> adj_unemployment_data = deepcopy(unemployment_data) 
156×1 TimeArray{Float64,2,Date,Array{Float64,2}} 2005-01-01 to 2017-12-01 

deepcopy 函数创建了一个对象的深拷贝,该对象作为参数给出。深拷贝意味着一切都会递归地复制,从而产生一个完全独立的对象。

接下来,我们可以使用 map 函数就地修改 TimeArray,通过递归应用一个将原始值除以季节性的函数:

julia> map(adj_unemployment_data) do d,v 
           v[1] /= norm_month_avg[month(d)] 
           d,v 
       end 
156×1 TimeArray{Float64,2,Date,Array{Float64,2}} 2005-01-01 to 2017-12-01 

结果如下:

adj_unemployment_data 变量代表季节调整后的时间序列。

时间序列运算符

在时间序列分析中,在TimeArray对象之间(或者更确切地说,在它们包含的元素之间)进行操作是一种常见现象。TimeSeries包公开了一套完整的元素级运算符,用于数学、比较和逻辑操作。

就像我们在两个TimeArray对象之间进行除法运算时已经看到的那样,数学运算符通过使用具有共同时间戳的值来创建一个新的TimeArray实例。也支持单个TimeArrayIntFloat之间的操作。以下运算符可用:

  • .+: 元素级数的算术加法

  • .-: 元素级数的算术减法

  • .*: 元素级数的算术乘法

  • ./: 元素级数的算术除法

  • .^: 元素级数的指数运算

  • .%: 元素级数的算术余数

与数学运算符类似,在比较运算符的情况下,当提供两个TimeArray实例时,值将在共享时间戳上进行比较。然而,这里的区别在于,结果将是一个类型为Bool的时间数组。

这些是可用的比较运算符:

  • .>: 元素级数的大于比较

  • .<: 元素级数的小于比较

  • .==: 元素级数的等价比较

  • .>=: 元素级数的大于或等于比较

  • .<=: 元素级数的小于或等于比较

  • .!=: 元素级数的非等价比较

让我们来看一个例子。首先,让我们创建一个从一周前到今天的TimeArray,并用随机值填充它。由于你将在未来某个时间运行代码,所以你的时间戳将不同,因此输出将与我的不同,但逻辑将是相同的。如果该模块尚未在作用域内,不要忘记执行using Dates

julia> using Dates 
julia> ts1 = TimeArray(Date(today()) - Week(1):Day(1):Date(today()) |> collect, rand(8)) 
8×1 TimeArray{Float64,1,Date,Array{Float64,1}} 2018-11-06 to 2018-11-13 

这就是我们得到的结果:

现在,我们将对第二个数组做同样的操作:

julia> ts2 = TimeArray(Date(today()) - Week(1):Day(1):Date(today()) |> collect, rand(8)) 
8x1 TimeSeries.TimeArray{Float64,1,Date,Array{Float64,1}} 2018-01-31 to 2018-02-07 

这就是我们得到的结果:

现在,我们可以比较两个对象,例如:

julia> tsc = ts1 .> ts2 
8×1 TimeArray{Bool,1,Date,BitArray{1}} 2018-11-06 to 2018-11-13 

输出如下:

也支持单个TimeArrayIntFloatBool值之间的比较:

julia> tsc .== false 
8×1 TimeArray{Bool,1,Date,BitArray{1}} 2018-11-06 to 2018-11-13  

现在,输出如下:

最后,我们可以使用以下逻辑运算符:

  • .& 元素级数的逻辑“与”

  • .| 元素级数的逻辑“或”

  • .!, .~ 元素级数的逻辑“非”

  • .``⊻ 元素级数的逻辑“异或”

它们是为TimeArrays类型Bool定义的,并返回一个类型为BoolTimeArray。当两个TimeArray对象是操作数,以及单个TimeArrayBool之间的操作时,值将在共同时间戳上计算。

时间序列*稳性

一个时间序列被认为是*稳的,如果其统计属性,如均值、方差、自相关等,随时间保持不变。*稳性很重要,因为大多数预测模型都是基于时间序列是*稳的或可以通过变换(*似)*稳的假设来运行的。这种方法的理由是,*稳时间序列中的值更容易预测——如果其属性是恒定的,我们就可以简单地声明它们在将来会像过去一样。一旦我们根据*稳时间序列预测了未来的值,我们就可以通过逆过程和变换来计算与原始序列匹配的值。

因此,*稳时间序列的性质不依赖于观察序列的时间。隐含地,这意味着呈现季节性或趋势的时间序列不是*稳的。在这种情况下,我们再次必须小心季节性和周期性的区别——不暴露季节性或趋势模式的周期性时间序列是*稳的。

时间序列的微分

使时间序列*稳的一种方法是通过微分。这意味着计算连续值之间的差分。在这种技术中,我们计算在特定时间点的值与之前时刻的值之间的差分。

这可以通过使用TimeSeries提供的diff方法轻松计算。对时间序列进行微分计算的是时间序列中两个连续点之间的有限差分。默认情况下,差分是以一天为单位的。例如,考虑以下内容:

julia> diffts = diff(unemployment_data) 
155×1 TimeArray{Float64,2,Date,Array{Float64,2}} 2005-02-01 to 2017-12-01 

原始系列中的一天在操作过程中丢失了,导致TimeArray从 2005 年 1 月 2 日开始,结果如下:

图片

我们可以将结果绘制成条形图:

julia> bar(diffts)

我们得到以下结果:

图片

整个数据集中值的变化清晰可见,这意味着方差相对恒定。

自相关

自相关表示时间序列与其滞后版本在连续时间间隔内相似的程度。这是一个非常重要的概念,因为它衡量了当前值与相应过去值之间的关系。因此,它在时间序列预测中有许多有价值的用途;例如,匹配价格、股票、回报等中的趋势和关系。

我们想使用自相关来确定我们是否可以可靠地识别因果关系和趋势——或者相反,我们是否在处理一个随机游走模型。随机游走意味着时间序列中的值是随机定义的,这将意味着过去和现在的值之间没有关系。随机游走模型很常见,尤其是在金融和经济数据中。对于随机游走模型,预测下一个值是通过取序列中的最后一个值来完成的。这是由于未来运动是不可预测的——它们同样可能增加或减少。因此,随机游走模型是简单预测的基础。

我们可以通过使用两个函数的组合——TimeSeries.lagxcorr来计算自相关。lag方法通过移动时间序列的值来实现。例如,让我们使用我们之前定义的ts1

julia> ts1 
8×1 TimeArray{Float64,1,Date,Array{Float64,1}} 2018-11-06 to 2018-11-13 

我们得到以下结果:

我们可以如下应用lag函数:

julia> lag(ts1) 
7×1 TimeArray{Float64,1,Date,Array{Float64,1}} 2018-11-07 to 2018-11-13 

这将导致第一个值被分配给下一个时间戳。在我的例子中,最初对应于2018-11-06的值0.3903现在对应于2018-11-07

请记住,如果您并行运行代码,您的数据将不同(由于我们使用随机值,实际日期和值是不同的),但行为将是相同的。

我们可以通过将失业数据滞后12个区间(12 个月)来考虑年度季节性:

julia> lagged = lag(unemployment_data, 12) 
144×1 TimeArray{Float64,2,Date,Array{Float64,2}} 2006-01-01 to 2017-12-01 

输出如下:

值已经移动,结果TimeArray从 2006 年 1 月 1 日开始。我们现在可以使用TimeSeries.merge在共同的时戳上连接两个序列:

julia> common = merge(unemployment_data, lagged) 
144×2 TimeArray{Float64,2,Date,Array{Float64,2}} 2006-01-01 to 2017-12-01 

这导致以下输出:

如果我们将原始失业数据与滞后一年的序列一起绘制,我们可以看到数据呈正相关,表明有强烈的年度季节性:

julia> plot(unemployment_data) julia> plot!(lagged) 

输出如下:

时间序列预测

预测意味着识别适合历史数据的模型,并使用它们来预测未来的值。在预测时间序列数据时,分解起着非常重要的作用,有助于使预测更加准确。基本假设是,如果我们单独预测每个组件,使用最适合的方法,然后将部分求和或相乘(取决于模型是加法还是乘法)来计算最终值。

简单预测

这是最简单的方法,它表明预测值等于序列中的最后一个值。如前所述,这种方法与随机游走模型一起使用,其中未来的移动是不可预测的。例如,为了使用简单模型预测第一个未知月份(2018 年 1 月)的值,我们可以从 2017 年 12 月取季节性调整值并加上(乘以)1 月份的季节性成分:

julia> update(unemployment_data, Date(2018, 1, 1), 
 TimeSeries.values(adj_unemployment_data[end])[:][end] * norm_month_avg[1] |> round) 
157×1 TimeArray{Float64,2,Date,Array{Float64,2}} 2005-01-01 to 2018-01-01 

我们使用TimeSeries.update方法为 2018 年 1 月添加一个新项目。其值是 2017 年 12 月的季节性调整值,乘以 1 月份的正常季节性:

图片

注意,我们还假设季节性成分没有变化,这意味着我们在季节性成分上使用的是季节性简单方法。

简单*均

一种稍微先进的方法是计算前几个数据点的*均值来预测下一个值。这是一个基本的方法,但在某些情况下,它可以是一个很好的选择。为了计算它,我们可以将mean函数应用于值的基本数组:

julia> mean(TimeSeries.values(adj_unemployment_data)[:]) 
21589.641025641027 

移动*均

我们在提取时间序列的趋势成分时详细介绍了移动*均。它也可以用于预测,使用计算结果来填充下一个值。重要的是要选择合适的窗口大小,通过理解序列的季节性,例如,使用自相关图。

线性回归

我们可以使用线性回归对季节性调整的时间序列进行预测下一个值。让我们更仔细地看看这一点,因为它提供了深入了解有趣的 Julia 代码的好机会。由于我们的数据呈现三个趋势(下降、上升和再次下降),让我们只关注最后一个部分,其中可以观察到当前下降趋势:

图片

我们可以看到,当前趋势始于失业高峰,所以我们只需要在序列中寻找最大值:

julia> findall(adj_unemployment_data[:Values] .== 
 maximum(TimeSeries.values(adj_unemployment_data)[:])) 
1-element Array{Int64,1}: 
 98 

julia> adj_unemployment_data[98] 
1×1 TimeArray{Float64,2,Date,Array{Float64,2}} 2013-02-01 to 2013-02-01 

我们得到以下值:

图片

下降趋势始于 2013 年 2 月。让我们从那时起提取所有数据,一直到序列的末尾:

julia> last_trend = from(adj_unemployment_data, Date(2013, 2, 1)) 
59×1 TimeArray{Float64,2,Date,Array{Float64,2}} 2013-02-01 to 2017-12-01 

结果如下:

图片

我们现在可以计算线性回归——它将总结失业数字与时间流逝之间的关系,使我们能够预测序列中的下一个值。我们在图的Y轴上有失业数字,在X轴上有时间。在这种情况下,我们可以用公式y = a+b*x来表示y,其中ab对应于线性回归。我们将对趋势序列进行线性回归以获得ab,并计算y的下一个值(失业预测),对应于x的下一个值(2018 年 1 月)。让我们一步一步地来做这件事。

我们需要做的第一件事是将时间序列中的时间戳转换为我们可以用于我们方程的简单整数序列:

julia> x = 1:length(last_trend) 
1:59 

julia> y = values(last_trend)[:] 
59-element Array{Float64,1}: 
 27790.0 
 27292.0 
# output truncated 

julia> linreg(x, y) = reverse([x ones(length(x))]\y) 

julia> a, b = linreg(x, y) 
2-element Array{Float64,1}: 
 27522.02805376972 
  -161.58229105786072  

x轴上,我们使用从159的整数,而不是实际的日期。在这个思路下,下一个值,我们想要预测的值将是x = 60,这意味着我们的下一个y(预测的失业值)将是27,608.61 + (-167.13 * 60)

julia> y = a+b*60 
17827.09059029808 

就像我们之前做的那样,我们需要为 1 月份添加季节性:

julia> y = y * norm_month_avg[1] |> round 
18573.0 

现在,我们可以将它附加到我们的失业数据上并绘制它:

julia> update(unemployment_data, Date(2018, 1, 1), y) |> plot 
julia> plot!(unemployment_data) 

结果如下所示:

图片

我们预测的值已经显示在图表上了。

结束语

应该提到的是,前面的部分只代表了一些最简单的预测方法。我们专注于对时间序列分解有一个良好的理解,这是分析和预测的关键工具。然而,还有更强大、更复杂的预测算法可用,例如自回归积分移动*均ARIMA)、人工神经网络ANN)和 Holt-Winters。这些算法适用于业务关键预测。我们现在已经为理解它们奠定了基础,但它们的实现更为复杂,超出了本章所假设的技术专长——特别是在撰写本文时,Julia 的包生态系统没有提供任何实现这些算法的库,我们不得不从头开始编写。

例如,一种常用的时间序列预测技术是 Holt-Winters 方法,也称为三重指数*滑。它基于加权移动*均和指数*滑,这两者都已经介绍过了。你可以在www.otexts.org/fpp/7/2www.otexts.org/fpp/7/5了解更多信息。

ARIMA 模型是另一种非常流行的预测算法。它们不使用趋势和季节性成分,而是专注于数据中的自相关性。如果你对此感兴趣,了解 ARIMA 模型的好起点是www.otexts.org/fpp/8

摘要

时间序列是一种非常常见的数据类型——它们可以用来表示关键的业务指标,如金融价格、资源使用(能源、水、原材料等)、天气模式或宏观经济趋势——这个列表可以一直列下去。时间序列的特殊之处在于数据必须以固定的时间间隔收集,时间序列分析的关键方面是探索允许我们理解过去值以便预测未来值的方法。

一种强大的方法是将时间序列分解为趋势、周期、季节性和不规则(也称为误差噪声)的组合。在我们分析欧盟的失业数据时,我们学习了如何做到这一点。我们首先通过移动*均法学习计算趋势成分。然后,我们应用了乘法序列分解公式来计算季节性和误差,并且我们还应用了基本的预测方法来预测未来的值。在这个过程中,我们学习了更多高级的TimeSeries方法,并且进一步对Plots进行了实验。这是一段相当刺激的经历——祝贺你!

在下一章中,我们将探讨一些更高级的主题,包括包的开发、用于衡量和改进性能的基准测试技术、生成文档以及注册包。多么令人兴奋——我们下一章见!

第十一章:创建 Julia 包

自我们从学习 Julia 的旅程开始以来,我们已经走了很长的路。我希望你享受这个发现过程,就像我一样!我们已经覆盖了很多领域,在开发一系列完全功能的应用程序的同时,学习了众多关键主题。然而,为了完全获得我们的 Julia 开发者徽章,我们还有一件事要做。每位熟练的 Julia 程序员的标志——(敲鼓声,请!)——创建、发布和注册我们自己的 官方 Julia 包!

在本章中,我们将构建一个 REPL 应用程序,并将其封装成一个包。我们的产品将帮助 Julia 开发者轻松地报告他们在其他 Julia 包中遇到的错误。一旦用户安装并配置了我们的包,他们就能直接在相应的仓库中打开 GitHub 问题,而无需离开他们的 REPL 或 IDE。在这个过程中,我们将学习到许多其他与 Julia 编程密切相关的重要方面,例如以下内容:

  • 使用 Pkg 搭建包

  • 包版本控制和依赖关系

  • Julia 中的测试驱动开发以及如何进行单元测试我们的代码

  • 基准测试和性能优化

  • 与 GitHub API 交互

  • 记录代码库并生成文档

  • 发布包(在 Julia 的机器人的一点点帮助下!)

准备好了吗?我确实希望如此。让我们开始吧!

技术要求

Julia 的包生态系统正在不断发展,每天都有新的包版本发布。大多数时候,这是一个好消息,因为新版本带来了新功能和错误修复。然而,由于许多包仍在测试版(版本 0.x)中,任何新版本都可能引入破坏性更改。因此,书中展示的代码可能会停止工作。为了确保你的代码会产生与书中描述相同的结果,建议使用相同的包版本。以下是本章中使用的外部包及其特定版本:

BenchmarkTools@v0.4.1
DocStringExtensions@v0.6.0
Documenter@v0.21.0
GitHub@v5.0.2
IJulia@v1.14.1
Traceur@v0.2.0
URIParser@v0.4.0

为了安装特定版本的包,你需要运行:

pkg> add PackageName@vX.Y.Z 

例如:

pkg> add IJulia@v1.14.1

或者,你也可以通过下载本章提供的 Project.toml 文件,并使用 pkg> 实例化来安装所有使用的包,如下所示:

julia> download("https://raw.githubusercontent.com/PacktPublishing/Julia-Programming-Projects/master/Chapter11/Project.toml", "Project.toml")
pkg> activate . 
pkg> instantiate

创建一个新的 Julia 包

为了创建一个新的包,我们首先必须满足一些先决条件。首先,我们需要在开发机器上安装并配置 git。显然,这是因为默认情况下,Julia 使用 git 和 GitHub (github.com/) 来托管包(尽管也可以使用第三方,包括私有包的注册表)。如果你的操作系统没有预装 git,请访问 git-scm.com/downloads 获取官方下载页面。选择适合你操作系统的正确版本,并按照安装说明进行操作。

第二,如果你还没有 GitHub 账户,你需要一个。请访问github.com并设置一个免费账户。

现在我们已经安装了git并拥有 GitHub 账户,让我们设置一些全局配置选项,因为它们将很有用。打开一个新的终端窗口并执行以下操作——请确保将<...>内的占位文本替换为你的实际信息:

$ git config --global user.name "<FULL_NAME>" 
$ git config --global user.email "<EMAIL>" 
$ git config --global github.user "<GITHUB_USERNAME>" 

例如,在我的情况下,第一个命令将是以下内容:

$ git config --global user.name "Adrian Salceanu" 

请通过运行git config -l来检查一切是否顺利。你应该会得到一个类似于我的输出:

$ git config -l 
user.name=Adrian Salceanu 
user.email=**@**  
github.user=essenciary 

极好!我们现在已经准备好开始设置我们的包。

生成包

Julia 的包管理器Pkg期望一定的文件结构以便管理依赖项、运行测试、构建二进制文件、生成文档等。幸运的是,我们不必手动创建所有这些:我们将使用Pkg本身,即generate命令。我们只需要传递我们包的名称。让我们称它为IssueReporter

julia> ] # enter Pkg mode pkg> generate IssueReporter Generating project IssueReporter: IssueReporter/Project.toml IssueReporter/src/IssueReporter.jl 

为我们创建了一个新文件夹,命名为IssueReporter/。在其中,我们可以找到一个Project.toml文件和一个子文件夹src/,它包含一个IssueReporter.jl文件。

Project.toml 文件

Project.toml文件对于Pkg来说非常特殊,因为它用于管理包及其依赖项。它旨在包含元信息,例如包的名称、其唯一标识符(称为UUID)、版本号、作者的姓名以及依赖项列表。Pkg已经预先填充了它,以帮助我们开始:

authors = ["Adrian Salceanu <*@*.com>"] # actual email truncated 
name = "IssueReporter" 
uuid = "7b29c13e-f3eb-11e8-2be5-fb20b77ad364" 
version = "0.1.0" 

[deps] 

如你所见,Pkg已经根据我的 Git 设置提取了正确的作者信息;它已经填写了包的名称并生成了一个新 UUID,并分配了版本号0.1.0

src 文件夹和主模块

src/文件夹也扮演着特殊角色。Julia 使用形式为<Package Name>/src/<Package Name>.jl的路径来识别包的入口点——即其主模块。当我们调用using IssueReporter时,将会搜索这个路径。为了让我们有一个良好的开始,IssueReporter.jl文件已经填充了一些代码行,足以启动相应的模块:

module IssueReporter 

greet() = print("Hello World!") 

end # module 

使用我们的新包

我们现在可以激活项目并加载我们的包:

julia> ; # enter shell mode 
shell> cd IssueReporter 
julia> ] # enter pkg mode 
pkg> activate . 
(IssueReporter) pkg>  

到目前为止,我们的包环境已经激活,并包含了模块:

julia> using IssueReporter 
[ Info: Precompiling IssueReporter [7b29c13e-f3eb-11e8-2be5-fb20b77ad364] 

julia> IssueReporter.greet() 
Hello World! 

极好——一切都已经设置好,准备让我们添加逻辑、测试和文档。

定义我们包的需求

我们项目的目标是创建一个 Julia 包,使得报告其他 Julia 包中的错误变得非常容易。我们希望允许我们库的用户访问一个简单的 API,用于程序化报告问题,而无需手动前往 GitHub (github.com)创建一个新的问题。

为了做到这一点,我们需要实现以下两个功能——一种找出已注册包的 GitHub URL 的方法;以及访问 GitHub API 在找到的仓库上注册新问题的手段。鉴于Pkg能够仅使用包名从 GitHub 克隆包,我们可以安全地假设这些信息与我们的 Julia 安装一起可用,并且我们将在某种方式下能够访问这些信息。然后,名为GitHub的包将帮助我们与 GitHub 的 API 进行接口。我们可以先添加它。请确保当前活动项目是IssueReporter。这应该由放在pkg>光标前的(IssuerReporter)前缀表示。如果不是这种情况,如前所述,您需要cd到我们的包目录,然后按照以下方式运行pkg> activate .

(IssueReporter) pkg> add GitHub  

当我们忙于这件事时,也可以添加URIParser包——我们将大量使用仓库 URL,这个功能将很有用:

 (IssueReporter) pkg> add URIParser 

此外,还有一件事——我们将使用测试驱动开发TDD)来构建我们的项目,因此我们还需要 Julia 的Test模块:

 (IssueReporter) pkg> add Test

到目前为止,所有包都已添加到依赖项列表中。您可以通过检查Project.toml文件来确认这一点,该文件位于[deps]部分,现在应显示以下内容:

[deps] 
GitHub = "bc5e4493-9b4d-5f90-b8aa-2b2bcaad7a26" 
Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" 
URIParser = "30578b45-9adc-5946-b283-645ec420af67" 

现在我们已经具备了添加逻辑的所有先决条件,采用 TDD 方式。

从测试驱动的 Julia 开发开始

测试驱动开发是一种基于简单工作流程的软件开发实践,将自动化测试置于核心位置。基本思想是将需求转化为非常具体、定义良好且具有针对性的测试用例。每个测试用例应仅针对一个功能点。一旦测试准备就绪,我们就运行整个测试套件。显然,由于我们首先编写测试,它最初会失败。接下来,我们添加最小实现以使测试通过。就是这样——我们所需做的就是重复相同的流程,直到所有需求都得到实现。这种方法确保我们的代码库得到彻底测试,并且我们专注于仅交付需求,避免功能蔓延。

Julia 在Test模块下提供了内置的单元测试功能。它非常简单易用,提供了足够的方法来覆盖所有基本测试场景:值和异常检查、*似值、类型等。

最重要的是 @test@test_throws@testset 宏。@test 宏检查作为参数传递的表达式是否评估为真,返回一个 PassResult。如果测试未通过,当表达式评估为 false 时,它将返回一个 FailResult——或者如果表达式根本无法评估,则返回一个 ErrorResult@test_throws 宏检查评估的表达式是否抛出异常。最后,@testset 用于将测试分组。测试集中的所有测试都将运行,并在测试集末尾显示结果摘要。如果任何测试失败,或由于错误无法评估,测试集将抛出 TestSetException

例如,考虑以下内容:

julia> using Test 

julia> @test 1 == 1 
Test Passed 

julia> @test 'A' == 'a' 
Test Failed 
  Expression: 'A' == 'a' 
   Evaluated: 'A' == 'a' 
ERROR: There was an error during testing 
# output omitted # 

前一个片段显示了通过和失败测试的输出。以下一个片段说明了测试集的使用,其中一个测试通过,另一个测试失败:

julia> @testset "Example" begin 
           @test :a == :a 
           @test 'x' == 'y' 
       end 
Example: Test Failed 
  Expression: 'x' == 'y' 
   Evaluated: 'x' == 'y' 

Test Summary: | Pass  Fail  Total 
Example       |    1     1      2 
ERROR: Some tests did not pass: 1 passed, 1 failed, 0 errored, 0 broken. 
# output omitted # 

最后,这是处理异常的方式:

julia> @testset "Example" begin 
           error("Oh no!") 
       end 
Example: Error During Test 
  Got an exception of type ErrorException outside of a @test 
  Oh no! 

Test Summary: | Error  Total 
Example       |     1      1 
ERROR: Some tests did not pass: 0 passed, 0 failed, 1 errored, 0 broken. 
# output omitted # 

现在我们已经了解了测试理论,让我们继续编写我们的第一个测试。我们需要一个方法,该方法将接受一个包名并返回相应的 GitHub 仓库 URL。这个 URL 将用于稍后与 GitHub API 交互并在相应的仓库中打开问题。目前,我们只需检查返回值是否为有效的 URL。我们将使用 URIParser 包来检查其有效性。

Julia 通过 Pkg 提供的功能使我们能够轻松添加和运行测试,再次通过 test 命令下的功能。当我们运行 (IssueReporter) pkg> test 时,Pkg 库将在 test/ 文件夹中查找名为 runtests.jl 的文件。

是时候在 Julia REPL 内部添加它们了:

julia> mkdir("test") 
"test" 

julia> touch("test/runtests.jl") 
"test/runtests.jl" 

现在,在编辑器中打开新创建的 runtests.jl 文件,例如,通过运行以下命令:

julia> edit("test/runtests.jl") 

请确保您的 runtests.jl 文件看起来像这样:

using IssueReporter 
using Test, URIParser, GitHub 

@testset "Basic features" begin 
  @testset "Looking up an existing package returns a proper repo URI" begin 
    @test IssueReporter.packageuri("DataFrames") |> URIParser.isvalid 
  end 
end 
"Basic features". Within it, we have yet another test set, which contains the actual test. Finally, the test invokes a function called packageuri from the IssueReporter module, passing it the  DataFrames string as its argument. What we're trying to do here is get the GitHub URI for a package that we know exists and is registered, namely DataFrames. Then, we make sure that the URI is valid by passing it into the URIParser.isvalid method.

一个测试集块,由 @testset 宏定义,将多个测试和/或其他测试集组合在一起。使用测试集的原因是,当测试失败时,会抛出异常,导致脚本执行停止。然而,当我们有一大批测试时,我们通常更喜欢允许所有测试运行,并得到一个完整的失败和成功的报告。使用测试集,集合内的所有测试都将运行,并在集合末尾显示摘要。如果任何测试失败,或由于错误无法评估,测试集将抛出 TestSetException

测试集应该有自解释的名称,与它们所代表的测试批次相关,因为这些标签在运行测试时会被输出。

我们可以按照以下方式运行测试:

 (IssueReporter) pkg> test

它将以一条信息丰富的消息失败:

(IssueReporter) pkg> test 
   Testing IssueReporter 
 Resolving package versions... 
Looking up an existing package returns a proper repo URI: Error During Test at IssueReporter/test/runtests.jl:7 
  Test threw exception 
  Expression: IssueReporter.packageuri("DataFrames") |> URIParser.isvalid 
  UndefVarError: packageuri not defined 
Test Summary:                                              | Error  Total 
Basic features                                             |     1      1 
  Looking up an existing package returns a proper repo URI |     1      1 
ERROR: LoadError: Some tests did not pass: 0 passed, 0 failed, 1 errored, 0 broken. 
ERROR: Package IssueReporter errored during testing 
# output omitted # 

重要的是 UndefVarError: packageuri 未定义。这并不令人惊讶,因为我们还没有定义 IssueReporter.packageuri 函数。

查看 Julia 的注册表

正如我们所说的,我们需要一种方法来根据软件包的名称检索软件包的 GitHub URI。现在,鉴于我们能够成功执行adddevelop等操作,而无需提供 GitHub URI,我们可以假设有一种方法可以将软件包名称转换为软件包 URL。

事实上,Julia 管理着一个所有已知软件包的仓库。这些软件包被分组到多个注册表中,并复制到您的计算机上。默认情况下,Julia 附带所谓的通用注册表,它位于您家目录中的.julia/文件夹中。通用注册表本身只是一个包含以英语字母表中的每个字母命名的子文件夹的文件夹(因此,从AZ)。在这些文件夹中,我们可以找到所有以该字母开头的软件包:

此截图显示了通用注册表的一部分,其中包含一些文件夹(从AD)和一些以字母D开头的软件包。

为了使软件包检索更高效,还在通用文件夹内放置了一个特殊的索引文件,称为Registry.toml。此文件定义了一个基于哈希的索引,将软件包 UUID 映射到包含namepath值的字典——path是相对路径,指向通用注册表内的一个文件夹。例如,这是对应于D3Trees软件包的条目,位于字母D下的第一个:

e3df1716-f71e-5df9-9e2d-98e193103c45 = { name = "D3Trees", path = "D/D3Trees" } 

接下来,如果我们查看D3Trees/文件夹本身,我们会看到它包含四个文件,每个文件都包含重要的元数据:

截图显示了属于D3Trees软件包的四个Pkg元数据文件。

Deps.toml文件包含了依赖项列表(D3Trees自身所需的软件包)。Compat.toml文件存储了依赖项和 Julia 版本的兼容性要求。Package.toml定义了诸如名称、UUID 和仓库 URL 等信息,最后,Versions.toml显示了所有已知的D3Trees版本及其相应的 Git 引用。看起来我们需要Package.toml文件中的信息。

因此,工作流程如下:

  1. 获取 Julia 的通用注册表的路径

  2. 读取Registry.toml文件

  3. 寻找我们要搜索的软件包名称

  4. 如果该软件包存在,则在通用注册表中获取其路径

  5. 读取相应的Package.toml文件

  6. 提取软件包的 GitHub URL

与 TOML 文件一起工作

Tom 的明显、最小语言TOML)是由 Tom Preston-Werner 创建的最小配置文件格式。TOML 文件与其他配置格式(例如著名的 INI)具有相同的目的——尽管 TOML 的目标是更容易阅读和解析。YAML 和 JSON 是其他非常流行的配置格式,你可能已经遇到过。Pkg广泛使用 TOML 来存储包元数据。

你可以在github.com/toml-lang/toml了解更多关于 TOML 的信息,包括完整的规范。

Julia 的 TOML 解析器可在github.com/wildart/TOML.jl找到,但我们不需要显式添加它,因为Pkg附带了一个我们将使用的 TOML 解析器。但这意味着我们必须将Pkg声明为IssueReporter的依赖项:

(IssueReporter) pkg> add Pkg 

现在,来实现前面的工作流程。首先,General注册表的路径。

Julia 跟踪一个存储重要信息的位置的列表。这些信息包括配置文件、环境、已安装的包和注册表。在 Julia 的术语中,这些被称为存储库,并存储在DEPOT_PATH全局变量中:

julia> DEPOT_PATH 3-element Array{String,1}: "/Users/adrian/.julia" "/Applications/Julia-1.0.app/Contents/Resources/julia/local/share/julia" "/Applications/Julia-1.0.app/Contents/Resources/julia/share/julia" 

我电脑上DEPOT_PATH数组的内容如下所示。你的输出将不同,但类似。

第一条记录是用户存储库,其中包含注册表的克隆、新包版本的安装、包仓库的克隆、日志文件的写入、默认检出开发包以及保存全局配置数据。存储库路径中的后续条目是只读的,用于系统管理员执行的操作。

让我们添加一个新的(失败的)测试来获取General注册表的路径:

@testset "Interacting with the registry" begin 
  @testset "The General registry is accessible" begin 
    IssueReporter.generalregistrypath() |> Base.Filesystem.isdir 
  end 
end 

关于实现,我们希望遍历DEPOT_PATH中的每个条目,并检查它是否包含registries/General目录路径。这些应该在用户存储库中,但更广泛的查找会使我们的代码更健壮:

function generalregistrypath() 
  for i in DEPOT_PATH 
    if isdir(joinpath(i, "registries", "General")) 
      return joinpath(i, "registries", "General") 
    end 
  end 
end 

一旦我们有了General注册表的路径,我们希望解析Registry.toml文件并提取与我们要搜索的包对应的信息。一旦解析,Registry.toml文件将生成一个包含五个条目的字典:

Dict{String,Any} with 5 entries: 
  "name"        => "General" 
  "repo"        => "https://github.com/JuliaRegistries/General.git" 
  "packages"    => Dict{String,Any}("c786d6c3-4fbc-59fc-968c-e848efb65d2d"=>Dict{String,Any}("name"=>"ScHoLP","path"=>"S/ScHoLP"),"88634af6-177f-5301-88b8-7819386cfa38"=>Dict{String,Any}("name"=>"SaferIntegers","path"=>"S/SaferIntegers")... 
  "uuid"        => "23338594-aafe-5451-b93e-139f81909106" 
  "description" => "Official general Julia package registry where people  
# output omitted #  

我们只对*packages*数据感兴趣,它看起来像这样:

Dict{String,Any} with 2358 entries: 
  "c786d6c3-4fbc-59fc-968c-e848efb65d2d" => Dict{String,Any}("name"=>"ScHoLP","path"=>"S/ScHoLP") 
  "88634af6-177f-5301-88b8-7819386cfa38" => Dict{String,Any}("name"=>"SaferIntegers","path"=>"S/SaferIntegers") 
  "aa65fe97-06da-5843-b5b1-d5d13cad87d2" => Dict{String,Any}("name"=>"SnoopCompile","path"=>"S/SnoopCompile") 
# output truncated # 

实际上,我们并不需要所有这些,因为我们不关心 UUID;只关心名称和路径。让我们向IssueReporter模块添加一个新函数,以反映这个规范:

function generalregistry() 
    TOML.parsefile(joinpath(generalregistrypath(), "Registry.toml"))["packages"] |> values |> collect 
end 

函数的输出类似于这个,一个Dict元素的数组:

2358-element Array{Any,1}: 
 Dict{String,Any}("name"=>"ScHoLP","path"=>"S/ScHoLP") 
 Dict{String,Any}("name"=>"SaferIntegers","path"=>"S/SaferIntegers") 
 Dict{String,Any}("name"=>"SnoopCompile","path"=>"S/SnoopCompile") 
# output truncated # 

一旦我们有了这个,通过名称进行包查找就非常容易了。我们只需遍历每个项目,并将"name"值与搜索字符串进行比较:

function searchregistry(pkgname::String) 
  for item in generalregistry() 
    item["name"] == pkgname && return item 
  end 
end 

识别出包的名称后,我们可以使用路径值来构建包含包元数据信息的文件夹的路径。记住,我们正在寻找Package.toml文件,因为它包含仓库 URI。

将所有这些放在一起,我们最终可以写出我们的IssueReporter.packageuri函数:

function packageuri(pkgname::String) 
  TOML.parsefile(joinpath(generalregistrypath(), searchregistry(pkgname)["path"], "Package.toml"))["repo"] 
end 

你的IssueReporter.jl文件应该看起来像这样:

module IssueReporter 

using Pkg, Pkg.TOML 

function generalregistrypath() 
  for i in DEPOT_PATH 
    if isdir(joinpath(i, "registries", "General")) 
      return joinpath(i, "registries", "General") 
    end 
  end 
end 

function generalregistry() 
    TOML.parsefile(joinpath(generalregistrypath(), "Registry.toml"))["packages"] |> values |> collect 
end 

function searchregistry(pkgname::String) 
  for item in generalregistry() 
    item["name"] == pkgname && return item 
  end 
end 

function packageuri(pkgname::String) 
  TOML.parsefile(joinpath(generalregistrypath(), searchregistry(pkgname)["path"], "Package.toml"))["repo"] 
end 

end # module 

IssueReporter.jl 包

再次运行测试将会成功:

(IssueReporter) pkg> test 

输出如下:

如果你好奇,根据IssueReporterDataFrames的 GitHub 仓库 URI 如下:

julia> IssueReporter.packageuri("DataFrames") 
https://github.com/JuliaData/DataFrames.jl.git

如果你想的话,可以在网络浏览器中自行检查以确认它确实是正确的 URI。

性能测试

我们代码到目前为止运行正确,但它的性能如何呢?除了其可读的语法、宽松的许可、丰富的包生态系统和友好的社区,性能也是数据科学家和软件开发者选择 Julia 的主要原因之一。编译器能够提供出色的性能,但有一些最佳实践是我们作为开发者必须牢记在心的,以确保我们基本上不会阻碍编译器。我们将通过查看一些示例并在运行基准测试的同时,来介绍最重要的几个。

基准测试工具

由于它专注于性能,所以 Julia 的核心和生态系统提供各种工具来检查我们的代码,寻找瓶颈并测量运行时间和内存使用,这并不令人惊讶。其中最简单的一个是@time宏。它接受一个表达式,然后打印其执行时间、分配的数量以及执行导致的总字节数,在返回表达式的结果之前。例如,注意以下内容:

julia> @time [x for x in 1:1_000_000]; 
  0.031727 seconds (55.85 k allocations: 10.387 MiB) 

通过迭代从一到一百万生成一百万个整数的数组需要 0.03 秒。不错,但如果我告诉你我们可以做得更好——好得多呢?我们犯了一个 Julia 的致命错误——代码不应该在全局范围内运行(或基准测试)。所以,第一条规则——总是将你的代码封装成函数。

之前的代码片段可以轻松重构如下:

julia> function onetomil() 
 [x for x in 1:1_000_000]
 end 
onetomil (generic function with 1 method) 

现在,基准测试如下:

julia> @time onetomil();
  0.027002 seconds (65.04 k allocations: 10.914 MiB) 

好吧,这确实更快——但并没有快很多。然而,如果我们再运行一次基准测试会怎样呢?

julia> @time onetomil();
  0.002413 seconds (6 allocations: 7.630 MiB) 

哇,这快了一个数量级!那么,这是怎么回事呢?

如果你记得我们关于函数和方法的介绍,Julia 使用的是即时编译JIT)编译器;也就是说,一个函数在第一次被调用时实时编译。因此,我们的初始基准测试也包括了编译时间。这引出了第二条规则——不要基准测试第一次运行。

因此,准确测量代码性能的最佳方式是多次执行它,然后计算*均值。有一个专为这种用例设计的优秀工具,称为BenchmarkTools。让我们添加它并尝试一下:

(IssueReporter) pkg> add BenchmarkTools 
julia> using BenchmarkTools 
julia> @benchmark onetomil() 
BenchmarkTools.Trial: 
  memory estimate:  7.63 MiB 
  allocs estimate:  2 
  -------------- 
  minimum time:     1.373 ms (0.00% GC) 
  median time:      1.972 ms (0.00% GC) 
  mean time:        2.788 ms (34.06% GC) 
  maximum time:     55.129 ms (96.23% GC) 
  -------------- 
  samples:          1788 
  evals/sample:     1 

BenchmarkTools 捕获了 1788 个样本,评估到样本的比例为 1。在这里,一个样本代表一个测量值,而一个评估是对基准表达式的执行。我们得到了最大 55 毫秒的时间,由垃圾回收驱动,最小为 1.3 毫秒,*均为 2.7 毫秒。这与第二个 @time 执行所揭示的 2.4 毫秒相符——但这个基准要准确得多。我们还可以使用更紧凑的 @btime 宏,它的输出类似于 @time,但执行了一个同样全面的基准:

julia> @btime onetomil(); 
  1.363 ms (2 allocations: 7.63 MiB) 

BenchmarkTools 提供了一个非常丰富的 API,值得深入了解。你可以在github.com/JuliaCI/BenchmarkTools.jl/blob/master/doc/manual.md了解更多信息。

类型稳定性至关重要

如果有一件事对 Julia 代码的性能有直接和巨大的影响,那就是类型系统。最重要的是编写类型稳定的代码。类型稳定性意味着变量的类型(包括函数的返回值)必须不会随时间或不同的输入而变化。了解如何利用类型稳定性是编写快速软件的关键。现在我们知道了如何测量代码的执行时间,我们可以通过几个例子看到类型不稳定性的影响。

让我们以这个看起来很无辜的函数为例:

julia> function f1() 
           x = 0 

           for i in 1:10 
               x += sin(i) 
           end 

           x 
       end 
f1 (generic function with 1 method) 

它并没有什么特别之处。我们有一个变量 x,它被初始化为 0,然后是一个从 110 的循环,我们将一个数字的正弦值加到 x 上。然后我们返回 x。没有什么可看的,对吧?实际上,恰恰相反——这里发生了一些性能上的不良情况。它们都与类型不稳定性有关。

Julia 提供了一个出色的工具来检查和诊断与类型相关的问题——@code_warntype 宏。当我们用它与我们的 f1 函数一起使用时,我们得到以下结果:

julia> @code_warntype f1() 

输出如下:

这次,我使用截图来展示输出,以便说明颜色编码。正如你所预期的那样,绿色是好的,红色是坏的。我还用矩形标记了红旗。问题出在第一行的 Body::Union{Float64, Int64},第 12 行的 (#4 => 0, #14 => %29)::Union{Float64, Int64},以及倒数第二行的 (#13 => %29, #4 => 0)::Union{Float64, Int64}

在第一行,Body::Union{Float64, Int64},以及倒数第二行,::Union{Float64, Int64},告诉我们同样的事情——该函数返回一个 Union{Float64, Int64},这意味着该函数可以返回一个 Float 或一个 Integer。这是典型的类型不稳定性,对性能来说是个坏消息。接下来,在第 12 行,某个东西 的类型是 Union{Float64, Int64},然后这个值作为函数的结果返回。如果你想知道,那个 某个东西 就是 x

问题在于我们无意中将x初始化为0,一个Integer。然而,sin函数将返回一个Float。将一个Float加到一个Integer上会导致结果是一个Float,从而改变x的类型。因此,x在函数执行过程中有两个类型,因为我们返回x,所以我们的函数也是类型不稳定的。

当然,理解@code_warntype的输出并不容易,尽管随着时间的推移它会变得容易一些。然而,我们可以通过使用超级有用的Traceur包来简化我们的工作。它提供了一个@trace宏,它生成易于理解的信息。让我们添加它并尝试一下;我相信你会喜欢的:

(IssueReporter) pkg> add Traceur 
julia> using Traceur 
julia> @trace f1() 
┌ Warning: x is assigned as Int64 
└ @ REPL[94]:2 
┌ Warning: x is assigned as Float64 
└ @ REPL[94]:4 
┌ Warning: f1 returns Union{Float64, Int64} 
└ @ REPL[94]:2 
1.4111883712180104 

这得多酷啊?清晰明了!

考虑到这个反馈,我们可以将我们的代码重构到一个新的f2函数中:

julia> function f2() 
           x = 0.0 

           for i in 1:10 
                  x += sin(i) 
           end 

           x 
       end 
f2 (generic function with 1 method) 

julia> @trace f2() 
1.4111883712180104

太棒了,没有要报告的事情!没有消息就是好消息!

现在,我们可以对f1f2进行基准测试,看看重构的结果:

julia> @btime f1() 
  129.413 ns (0 allocations: 0 bytes) 
1.4111883712180104 

julia> @btime f2() 
  79.241 ns (0 allocations: 0 bytes) 
1.4111883712180104 

这真不错——79 纳秒与 129 纳秒的对比!如果你在想“这不过是 50 纳秒,有什么大惊小怪的?”,那你得这样看——f2,这个类型稳定的变体,比f1快*一倍!这可是个真正的大事!

基准测试我们的代码

是时候将我们所学应用到自己的代码库中了。请注意,我故意加入了一些问题,让事情变得更有趣。让我们一起来解决它们:

julia> @code_warntype IssueReporter.packageuri("DataFrames") 

输出如下:

这一切都非常有趣——让我们看看我们能从中学到什么。

从第 1 行开始,IssueReporter.generalregistrypath函数返回一个Union{Nothing, String}。原因是我们的函数没有处理for循环未进入或if语句未执行的情况。我们应该确保我们的函数总是返回一个值,并且这个返回值的类型不会改变。为了更加保险,我们还可以在函数定义本身中添加类型断言。如果我们意外地返回了错误类型,Julia 将尝试将其转换为声明的类型——如果这不起作用,则会抛出错误。

我们需要将函数重新定义为以下内容:

function generalregistrypath() :: String 
  for i in DEPOT_PATH 
    if isdir(joinpath(i, "registries", "General")) 
      return joinpath(i, "registries", "General") 
    end 
  end 

  "" 
end 

现在,转到以%2开头(第三行)的行——searchregistry函数返回一个Any类型的值。这里的问题是,我们返回了从generalregistry调用中来的一个项,因此我们需要先看看那个。我们将添加对generalregistrypath返回值的检查,并添加一个默认返回值,一个空的Vector{Dict{String,Any}}。然后,对于searchregistry,我们也将添加一个默认返回值——因为它从这个Vector中返回一个项,所以它将是Dict{String,Any}类型。

接下来,关于packageuri函数,在以%9开头(第 11 行)的行中,我们可以看到有关KeyErrorrepo的信息。Julia 正在警告我们,可能我们没有名为repo的键,这会导致KeyError。此外,该函数返回一个Any类型的对象。

这里是三个重构后的函数:

function generalregistry() :: Vector{Dict{String,Any}} 
  if ! isempty(generalregistrypath()) 
    TOML.parsefile(joinpath(generalregistrypath(), "Registry.toml"))["packages"] |> values |> collect 
  else 
     Dict{String,Any}[] 
   end 
end 

function searchregistry(pkgname::String) :: Dict{String,Any} 
  for item in generalregistry() 
    item["name"] == pkgname && return item 
  end 

  Dict{String,Any}() 
end 

function packageuri(pkgname::String) :: String 
  pkg = searchregistry(pkgname) 
  isempty(pkg) && return "" 
  get!(TOML.parsefile(joinpath(generalregistrypath(), pkg["path"], "Package.toml")), "repo", "") 
end 

我们现在可以重新检查我们的代码:

julia> @code_warntype IssueReporter.packageuri("DataFrames") 

输出如下:

太棒了,几乎一切都很绿色!只有一个红色的 Any,来自 TOML.parsefile 函数本身,但这不值得优化掉;额外的努力会抵消好处。

花些时间查看官方的性能建议绝对是值得的,这些建议可在docs.julialang.org/en/v1/manual/performance-tips/在线找到。

与 GitHub API 交互

现在我们可以在 General 注册表中检索任何包的 GitHub URI,我们可以使用它来与 GitHub API 交互。Julia 开发者可以访问由 GitHub 包提供的强大 GitHub 库。这就是我们将用它来在包的 GitHub 仓库中创建新问题的方法。

使用 GitHub API 进行身份验证

为了允许与 GitHub API 交互,我们必须进行身份验证。这将允许我们的包在用户的账户下执行操作,就像直接通过网站执行一样。请访问 github.com/settings/tokens/new 来设置新的 GitHub 访问令牌。如果您不熟悉这个概念并且想了解更多,请继续阅读并遵循help.github.com/articles/creating-a-personal-access-token-for-the-command-line/中的官方说明。为令牌提供一个好的描述,并且非常重要,确保您检查了仓库范围,就像您可以在下面的截图中所看到的那样:

生成后,请记下令牌——一旦离开该页面,您就再也看不到它了。

访问令牌必须谨慎操作——并且不得提交到 git 或其他源代码控制系统中,这些系统可能被其他用户访问。任何获得您的访问令牌的人都可以用它来在 GitHub 上冒充您。为了安全起见,请确保对于这个项目,您只检查仓库范围。

让我们在不损害安全性的前提下,给我们的包添加一些逻辑,以便使访问令牌可用。它应该按以下方式工作——首先,我们检查访问令牌是否作为命令行参数提供给 Julia 进程——这意味着它将在 ENV 集合中可用。如果没有,我们将在包的根目录中寻找一个名为 secrets.jl 的文件并将其包含进来。该文件将包含访问令牌,因此我们将将其添加到 .gitignore 中,以确保它不会意外地提交到 git。

因此,让我们编写测试。在 runtests.jl 的末尾追加以下内容:

@testset "GitHub integration" begin 
  delete!(ENV, "GITHUB_ACCESS_TOKEN") 

  @testset "An undefined token should return false" begin 
    @test ! IssueReporter.tokenisdefined() 
  end 
  @testset "Attempting to access a token that is not set will error" begin 
    @test_throws ErrorException IssueReporter.token() 
  end 
  # setup a mock token 
  ENV["GITHUB_ACCESS_TOKEN"] = "1234" 
  @testset "Token is defined" begin 
    @test IssueReporter.tokenisdefined() 
  end 
 @testset "A valid token is a non empty string and has the set value" begin 
    token = IssueReporter.token() 
    @test isa(token, String) && ! isempty(token) 
    @test token == "1234" 
  end 
end 

测试当然会失败,所以让我们让它们通过。

将以下函数定义添加到 IssueReporter.jl

function tokenisdefined() :: Bool 
    if ! haskey(ENV, "GITHUB_ACCESS_TOKEN") 
        secrets_path = joinpath(@__DIR__, "secrets.jl") 
        isfile(secrets_path) && include(secrets_path) 
        haskey(ENV, "GITHUB_ACCESS_TOKEN") || return false 
    end 

    true 
end 

function token() :: String 
    tokenisdefined() && return ENV["GITHUB_ACCESS_TOKEN"] 
    error("""ENV["GITHUB_ACCESS_TOKEN"] is not set -- please make sure it's passed as a command line argument or defined in the `secrets.jl` file.""") 
end 

tokenisdefined函数中,我们检查GITHUB_ACCESS_TOKEN环境变量是否已经定义——如果没有,我们检查secrets.jl文件是否存在,如果存在,则将其包含进来。一旦包含,secrets.jl文件应该定义该变量,因此最后我们再次检查GITHUB_ACCESS_TOKEN的存在。如果令牌仍然未定义,函数返回false——否则,返回true。我们还添加了一个调用tokenisdefined的令牌函数,给模块设置GITHUB_ACCESS_TOKEN的机会。如果令牌可用,则返回它——如果不可以,这次会抛出一个错误。我们的测试现在应该通过了:

pkg> test 

如此,正如这里所示:

图片

成功!

在继续之前,我们需要将secrets.jl文件添加到.gitignore中——将此提交到公共 GitHub 仓库将是一个巨大的安全错误。从 Julia REPL 中,请注意以下内容:

julia> write(".gitignore", "secrets.jl") 

现在,你需要创建secrets.jl文件本身,并确保它包含类似于以下片段的内容,但添加你自己的 GitHub 访问令牌:

ENV["GITHUB_ACCESS_TOKEN"] = "0cdf8672e66***" # token truncated 

太好了,我们现在准备好报告问题!

报告 GitHub 问题

我们现在只剩下最后一步——使用 GitHub API 来报告问题。为了注册一个问题,我们需要两个信息——标题和正文。因此,我们将定义一个新的函数,称为report,它将接受三个字符串参数——包名,以及两个用于问题标题和正文的参数。内部,该函数将通过 GitHub 包对相应的 GitHub API 进行认证调用。

根据文档,调用GitHub.create_issue方法看起来是这样的:

GitHub.create_issue("<username>/<repo>", auth = <GitHub.OAuth2>, params...) 

这意味着我们需要做以下事情:

  1. 使用 GitHub 令牌进行认证并生成所需的GitHub.OAuth2认证对象

  2. 从 Julia 包名开始,计算 GitHub 用户名和仓库信息——为此,我们将使用已经实现的IssueReporter.packageurl,并对其进行一些额外的处理以从 URL 中移除不需要的部分

  3. 调用GitHub.create_issue,传入所有必要的参数

由于我们正在进行 TDD,让我们首先将这些规范转换为测试。在runtests.jl文件的底部添加以下内容:

@testset "Adding GitHub issues" begin 
  delete!(ENV, "GITHUB_ACCESS_TOKEN") 

  @testset "Successful authentication should return a GitHub.OAuth2 instance" begin 
    @test isa(IssueReporter.githubauth(), GitHub.OAuth2) 
  end 
  @testset "Converting package name to GitHub id" begin 
    @test IssueReporter.repoid("IssueReporter") == "essenciary/IssueReporter.jl" 
  end 
  @testset "Submitting an issue should result in a GitHub.Issue object" begin 
    @test isa(IssueReporter.report("IssueReporter", "I found a bug", "Here is how you can reproduce the problem: ..."), GitHub.Issue) 
  end 
end 

测试与之前用普通英语表达的要求完全相同,顺序一致。第一个调用一个我们将要编写的函数,称为IssueReporter.githubauth,该函数将执行 GitHub 认证,并在成功时返回一个GitHub.OAuth2对象。接下来,我们需要一个新的repoid函数,它将接受一个包名,并返回 GitHub 用户名和仓库名。请注意,我们正在使用IssueReporter包的我的仓库作为测试的豚鼠。最后,我们测试问题创建,这将通过IssueReporter.report方法完成——在成功的情况下,我们期望得到一个GitHub.Issue对象。

不要用 Julia 做任何邪恶的事情!我们编写的代码实际上会在 GitHub 的实时仓库上注册新问题。请尊重开源贡献者的辛勤工作,不要用虚假的问题来压垮他们。

是时候通过编写实现来确保测试通过了。确保IssueReporter模块的using指令如下所示:

using Pkg, Pkg.TOML, GitHub, URIParser  # we've added URIParser and GitHub 

然后,将以下函数添加到IssueReporter模块的底部:

function githubauth() 
  token() |> GitHub.authenticate 
end 

function repoid(package_name::String) 
  pkg_url = packageuri(package_name) |> URIParser.parse_url 
  repo_info = endswith(pkg_url.path, ".git") ? 
                replace(pkg_url.path, r".git$"=>"") : 
                pkg_url.path 
  repo_info[2:end] 
end 

function report(package_name::String, title::String, body::String) 
  GitHub.create_issue(repoid(package_name), auth = githubauth(), 
                        params = Dict(:title => title, :body => body)) 
end 

非常直接。githubauth函数调用GitHub.authenticate方法,并传递由 token 函数调用提供的认证令牌。

repoid方法接受一个字符串参数作为仓库名称,然后调用packageuriURIParse.parse_url来生成对应 GitHub 仓库的 URI 对象。然后我们提取 URI 的路径组件并对其进行处理,只保留 GitHub 用户名和仓库名。换句话说,从名为IssueReporter的包开始,我们检索 GitHub 仓库 URL,它是git://github.com/essenciary/IssueReporter.jl.git。路径组件是/essenciary/IssueReporter.jl.git。我们使用r".git$"正则表达式来移除返回子串前的.git后缀。最后,我们得到了我们需要的——essenciary/IssueReporter.jl

最后,报告函数通过调用GitHub.create_issue方法,并传递repoid、认证对象以及问题标题和正文的Dict来将所有内容整合在一起。现在所有测试都应该通过,并且问题已经在 Github.com 上成功创建(github.com/):

请注意,本章提供的示例代码中的create issue功能已被注释掉——取而代之的是硬编码了一个虚拟仓库。出于对真实仓库的贡献者和追随者的尊重,实际的问题将创建在我特别为此目的创建的虚拟仓库上。

记录我们的包

我们的产品包现在已经完整!让我们让用户能够轻松利用IssueReporter提供的惊人便利——我们将为他们提供一份信息丰富的文档。我们已经知道如何通过使用DocStrings来记录我们的代码——这些可以被我们自己和其他开发者用来理解我们的源代码。它也被 REPL 的帮助系统所使用(记得从第一章,开始使用 Julia 编程,你可以在行首输入?来将 REPL 切换到帮助模式)。你会很高兴地听到,我们还可以使用相同的DocStrings,借助一个名为Documenter的包来生成包文档。请使用(IssueReporter) pkg> add Documenter来添加它。

因此,首先要做的是在我们的函数中添加一些 DocStrings。请记住,官方 建议包括函数的签名、一小段描述和一些示例。例如,IssueReporter.packageuri 函数的文档可能看起来像这样:

""" packageuri(pkgname::String) :: String 
Takes the name of a registered Julia package and returns the associated repo git URL. 
Examples ``` jldoctest julia> `IssueReporter.packageuri("IssueReporter")` "git://github.com/essenciary/IssueReporter.jl.git" ```py """ function packageuri(pkgname::String) :: String # output truncated # end 

高级文档技巧

packageuri function in the DocString. The problem here is that the documentation can get out of sync if we change the function declaration, but omit to update the documentation. Julia's package ecosystem provides a library that extends the default documentation functionality, named DocStringExtensions. It's a registered package, so it can be added with (IssueReporter) pkg> add DocStringExtensions. It provides a series of methods that can be used to automatically generate some of the repetitive parts of the documentation process. For example, once we add using DocStringExtensions to the IssueReporter module, we can replace the function declaration from the docstring with the $(SIGNATURES) *abbreviation*. We'll see how to do that right away.

DocStrings 的另一个宝贵特性是示例也可以作为测试。这种测试称为 doctest。基本上,当我们提供一个交互式示例及其相应的输出时,如果我们将其标记为 jldoctest,用于生成文档的 Documenter 包也会运行这些示例,并将结果与提供的输出进行比较,从而测试示例,并隐含地测试我们的代码。查看下一个片段,看看在应用这些优化后前一个示例的样子。

我已经为表示 IssueReporter公共 API 的所有函数添加了注释。以下是更新后的函数定义(你可以从本章的仓库中获取完整文件,链接为 github.com/PacktPublishing/Julia-Programming-Projects/blob/master/Chapter11/IssueReporter/src/IssueReporter.jl):

module IssueReporter 

using Pkg, Pkg.TOML, GitHub, URIParser, Documenter, DocStringExtensions 

# ... some functions omitted ... #

""" 
$(SIGNATURES) 

Takes the name of a registered Julia package and returns the associated repo git URL. 

#Examples 
```julia-repl

julia> `IssueReporter.packageuri("IssueReporter")`

"git://github.com/essenciary/IssueReporter.jl.git"

```py 
""" 
function packageuri(pkgname::String) :: String 
    # ... function body omitted ... # 
end 

""" 
$(SIGNATURES) 

Checks if the required GitHub authentication token is defined. 
""" 
function tokenisdefined() :: Bool 
  # ... function body omitted ... # 
end 

# ... some functions omitted ... #

""" 
$(SIGNATURES) 

Converts a registered Julia package name to the corresponding GitHub "username/repo_name" string. 

#Examples 
```jldoctest

julia> `IssueReporter.repo_id("IssueReporter")`

"essenciary/IssueReporter.jl"

```py 
""" 
function repoid(package_name::String) 
    # ... function body omitted ... # 
end 

# ... some functions omitted ... #

end # module  

生成文档

为了创建我们的文档,我们首先需要在 IssueReporter 根目录内创建一个 docs/ 文件夹。

docs/ 文件夹中,我们还需要两样东西——首先是一个 src/ 文件夹,它将包含用于构建文档的 markdown 模板,以及 index.md 文件;其次是一个 make.jl 文件,它将控制文档构建过程。以下是我们的包的完整文件结构,仅供参考:

现在,在编辑器中打开 docs/make.jl 文件并添加以下内容:

using Pkg 
pkg"activate .." 
push!(LOAD_PATH,"../src/") 
using Documenter, IssueReporter 

makedocs(sitename = "IssueReporter Documentation") 

接下来,在编辑器中打开 index.md 文件并添加以下内容:

# IssueReporter.jl Documentation 
```@meta

`CurrentModule = IssueReporter`

```py 
```@contents

```py 
## Functions 
```@docs

`packageuri(pkgname::String)`

`tokenisdefined()`

`token()`

`githubauth()`

`repoid(package_name::String)`

`report(package_name::String, title::String, body::String)`

```py 
## Index 
```@index

```py 

这是我们的文档的 Markdown 模板。在顶部,我们有页面的标题。然后,@meta块包含传递模块名称的Documenter信息。@contents块将被替换为目录。@docs块将包含每个包含函数的文档。在底部,@index块将被替换为指向每个已记录函数的链接列表。

那就是全部了。为了生成文档,我们需要在docs/文件夹中从 OS 终端运行$ julia --color make.jl

命令的输出将显示构建文档的进度:

Documenter: setting up build directory. 
Documenter: expanding markdown templates. 
Documenter: building cross-references. 
Documenter: running document checks. 
 > checking for missing docstrings. 
 > running doctests. 
 > checking footnote links. 
Documenter: populating indices. 
Documenter: rendering document. 

生成的文档可以在docs/build/index.html中找到,看起来像这样:

图片

注册我们的包

现在,对于最后一步——让我们的包对全世界可用!首先,我们需要创建远程 GitHub 仓库并将我们的代码推送到它。最简单的方法是使用 GitHub 提供的hub二进制文件。请按照您*台上的说明进行安装,请参阅github.com/github/hub。一旦准备好,我们将在IssueReporter文件夹的根目录下运行hub create。我们可以在 Julia 的 REPL 中这样做:

julia> cd(Pkg.dir("IssueReporter")) 
julia> run(`hub create IssueReporter.jl`) 

您将被提示输入您的 GitHub 用户名和密码——如果一切顺利,您将看到确认创建仓库的输出。

最后的修饰

接下来,我们需要提交和推送我们的更改——但在做之前,让我们先对.gitignore进行最后的修改,将docs/build也添加到忽略文件列表中。将构建的文档包含在 GitHub 提交中是一种不好的做法——有关在 GitHub 上托管文档的更多信息,请阅读官方Documenter信息,请参阅juliadocs.github.io/Documenter.jl/latest/man/guide/#Usage-1juliadocs.github.io/Documenter.jl/latest/man/hosting/#Hosting-Documentation-1

当我们在这里时,让我们也向IssueReporter的根目录添加一个README.md文件,以包含一些信息:

# IssueReporter.jl  
`IssueReporter.jl` is a Julia package which makes it easy to report a new issue with a registered package. 
In order to use it, it needs to be configured with a valid GitHub authentication token. Follow the instructions at 
https://help.github.com/articles/creating-a-personal-access-token-for-the-command-line/ to generate a new token -- make sure 
that it has the `repo` access. 
Once you have the token, add it to the secrets.jl file. 
You can now open issues by invoking: 
`IssueReporter.report("Julia package name", "issue title", "issue body")` 

设置仓库

使用您喜欢的 git 客户端,addcommitpush代码库。我将使用终端:

$ git add . $ git commit -m "initial commit" $ git push origin master 

解放 Julia 的机器人军团

我们的项目看起来很棒——现在是时候标记一个版本并注册它了。

Julia 的贡献者开发了一系列非常实用的 GitHub 集成,即机器人。这些机器人帮助我们人类自动化一系列无聊的任务,以便我们可以专注于真正重要的事情(嗯,披萨!)。

其中之一是 Attobot,它是 Julia 的包发布机器人。当在 GitHub 上标记版本时,它会为 Julia 的General注册表创建拉取请求。尝试以下操作:

  1. 要设置 Attobot,请打开您的 IssueReporter GitHub 仓库并访问 github.com/integration/attobot。请确保您已登录到您的 GitHub 账户。

  2. 然后,点击配置以选择您希望添加的仓库。

  3. 仅选择仓库,然后选择 IssueReporter 并点击保存。现在,Attobot 已配置为监控具有标准 .jl 扩展名的包,并在标记新版本时将它们发布到 Global 仓库。

有关 Attobot 的更多详细信息,请访问 github.com/attobot/attobot

  1. 现在,我们需要前往我们的仓库的 GitHub 页面并点击版本链接:

  1. 接下来,我们将有创建新版本的选择:

  1. 在下一屏幕上,我们将能够标记我们的版本。Julia 使用语义版本控制(看起来像 vX.Y.Z),并建议从 v0.0.1 开始。让我们就这样做:

  1. 然后,点击发布版本。

如果有任何问题,Attobot 将在仓库中打开问题——确保您已经解决了它们。一旦完成,该包将被注册!胜利!

摘要

看到我们的包终于准备就绪,真是令人兴奋!

在开发过程中,我们还学习了关于 Julia 强大工具箱的知识,以及一些关于软件开发的一般最佳实践——TDD、单元测试、基准测试,以及记录我们的代码库并发布生成的文档。

这也标志着我们学习 Julia 语言的旅程的结束。自从第一次打开 REPL 以来,我们已经走了很长的路——您已经取得了令人印象深刻的成就!数据分析、绘图、网络爬取、推荐系统、监督和未监督机器学习,以及时间序列分析和预测!现在您可以使用 Julia 做所有这些事情。哇!这确实是一个相当令人印象深刻的记录!而且,如果做所有这些看起来很简单,那都是因为 Julia 的不可思议的特性。高效的 REPL、简单的包安装、方便的绘图包,或者可读的语法;它们都让编程变得简单而有趣。

Julia 确实是一种新型的编程语言。由于它是新的,它能够通过借鉴最成功的编程语言的优势,避免它们的错误来学习。Julia 被特别设计来高效地满足我们这一代人的需求——机器学习、人工智能、高性能、并行、GPU 和分布式云计算——这些都是在这些领域表现出色的领域。

但,Julia 不仅提供了编写高性能代码的高效语言结构——它还提供了富有成效的开发体验。强大的 REPL(所有现有编程语言中最好的 REPL 之一!)和即时编译功能使得快速原型设计解决方案、切割和分割大量数据,或即时实验数据模型变得容易。集成的帮助模式和强大的 shell 模式赋予了开发者力量,提升了生产力。

然后是通过 IJulia 与 Jupyter Notebooks 的无缝集成——以及与 Python 和 R 等现有编程语言的惊人跨语言集成。如果您已经使用过这些技术,切换到 Julia 应该会很直接。

但 Julia 是新的,刚刚达到版本 1,并不意味着 Julia 不是一个成熟的编程语言。它经过六年的精心打磨和关注——来自数千名开发者的贡献。因此,我鼓励您开始使用 Julia 来解决实际问题。您将加入成千上万的开发者行列,他们正在专业地使用这种语言进行科学计算、数据科学、人工智能、金融科技、Web 开发、教学等领域。像苹果、亚马逊、Facebook 和甲骨文这样的知名公司——仅举几个例子——在 2017 年都在寻找 Julia 程序员。

我希望您阅读这本书的乐趣和我写作这本书的乐趣一样。您现在已经准备好了——我希望您渴望——在您的项目中使用 Julia!所以,与其说“再见”,我更想说的是——欢迎来到 Julia 编程的奇妙世界!

posted @ 2025-09-03 10:08  绝不原创的飞龙  阅读(1)  评论(0)    收藏  举报