Python-和-JavaScript-数据可视化指南第二版-全-

Python 和 JavaScript 数据可视化指南第二版(全)

原文:zh.annas-archive.org/md5/83a6dfbdf8d44d9bb55fbfe0c46e09a0

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

本书的首要目标是描述一种数据可视化(dataviz)工具链,在互联网时代开始主导。这个工具链的指导原则是,无论您从数据中挖掘出多少见解,都应该在 Web 浏览器上找到它们的家园。在 Web 上意味着您可以轻松选择将您的 dataviz 分发给少数人(使用身份验证或限制在本地网络)或整个世界。这是互联网的重大理念,也是 dataviz 正在快速接受的理念。这意味着 dataviz 的未来涉及 JavaScript,这是 Web 浏览器唯一的一级语言。但 JavaScript 尚未具备处理原始数据所需的数据处理堆栈,这意味着数据可视化不可避免地是多语言的事务。我希望本书能够支持我的观点,即 Python 是 JavaScript 在浏览器可视化中的自然补充语言的支持者。

尽管这本书很庞大(这一事实现在作者最为深切地感受到),但它必须非常选择性地撰写,留出了许多真正酷炫的 Python 和 JavaScript 数据可视化工具,重点是那些提供最佳构建模块的工具。我未能覆盖的有用库的数量反映了 Python 和 JavaScript 数据科学生态系统的巨大活力。即使在书写过程中,也在不断推出出色的新 Python 和 JavaScript 库,并且这个步伐还在继续。

所有的数据可视化本质上都是转变的,展示从数据集的一种反映(HTML 表格和列表)到更现代、引人入胜、互动性更强、基本上是基于浏览器的方式,为工作环境中介绍关键数据可视化工具提供了一个很好的方式。挑战在于将基本的维基百科诺贝尔奖获奖者列表转化为现代、互动式、基于浏览器的可视化。因此,同样的数据集以更易访问、引人入胜的形式呈现。

从未加工的数据到相当丰富的用户驱动可视化的旅程,决定了选择最佳工具的方式。首先,我们需要获取我们的数据集。通常这是由同事或客户提供的,但为了增加挑战并学习一些非常重要的数据可视化技能,我们学习如何使用 Python 强大的 Scrapy 库从网络(维基百科的诺贝尔奖页面)抓取数据集。然后需要对这些未加工的数据集进行精炼和探索,而 Python 的 pandas 生态系统是最好的选择之一。连同 Matplotlib 的支持,并通过 Jupyter 笔记本驱动,pandas 正成为这种法庭数据工作的黄金标准。随着干净的数据被存储(使用 SQLAlchemy 和 SQLLite)和探索,可以将精心挑选的数据故事可视化。我介绍了使用 Matplotlib 和 Plotly 将 Python 生成的静态和动态图表嵌入网页的方法。但是对于更雄心勃勃的项目,基于 JavaScript 的 D3 是网页的终极数据可视化库。我们在使用 D3 的同时,探索了如何制作我们的招牌诺贝尔数据可视化作品。

本书是一个工具集合的集合,以创建诺贝尔奖可视化作品为引导叙事。如果需要时,您应该能够随时查阅相关章节;本书的不同部分都是独立的,因此您可以在需要时快速复习您学到的内容。

本书分为五个部分。第一部分介绍了基础的 Python 和 JavaScript 数据可视化工具包,而接下来的四部分展示了如何检索原始数据、清理数据、探索数据,最终将其转换为现代网络可视化。现在让我们总结每个部分的关键教训。

第一部分:基础工具包

我们的基础工具包包括:

  • 一种连接 Python 和 JavaScript 的语言学习桥梁。这旨在平滑两种语言之间的过渡,突出它们的许多相似之处,并为现代数据可视化的双语过程铺平道路。随着最新 JavaScript 的出现^(1),Python 和 JavaScript 之间有更多共同之处,使得在它们之间切换变得更少压力。

  • 能够轻松读取和写入关键数据格式(例如 JSON 和 CSV)和数据库(包括 SQL 和 NoSQL)是 Python 的一大优势。我们看到在 Python 中传递数据的简易程度,可以在进行格式转换和数据库更改的同时移动数据。这种数据的流动是任何数据可视化工具链的主要润滑剂。

  • 我们涵盖了开始制作现代、交互式、基于浏览器的数据可视化所需的基本网页开发(webdev)技能。通过专注于单页应用程序的概念而不是构建整个网站,我们将传统的网页开发最小化,并将重点放在使用 JavaScript 编程您的可视化创作上。对可扩展矢量图形(SVG)的介绍,这是 D3 可视化的主要构建块,为我们在第五部分中创建诺贝尔奖可视化做好了准备。

第二部分:获取您的数据

在本书的这一部分中,我们将看看如何使用 Python 从 Web 获取数据,假设数据可视化者没有提供一个好的、干净的数据文件:

  • 如果你幸运的话,一个干净的文件以易于使用的数据格式(即 JSON 或 CSV)出现在一个开放的 URL 上,只需简单的 HTTP 请求即可获得。或者,可能有一个专用的 web API 用于您的数据集,有幸是一个符合 REST 原则的 API。作为示例,我们将介绍如何使用 Twitter API(通过 Python 的 Tweepy 库)。我们还将看到如何使用 Google 表格,这是数据可视化中广泛使用的数据共享资源。

  • 当感兴趣的数据以人类可读形式出现在 Web 上时,情况就会变得更加复杂,通常以 HTML 表格、列表或分层内容块的形式存在。在这种情况下,您必须诉诸于抓取,获取原始 HTML 内容,然后使用解析器使其嵌入内容可用。我们将看到如何使用 Python 的轻量级 Beautiful Soup 抓取库以及功能更丰富、更重量级的 Scrapy,这是 Python 抓取界的最大明星。

第三部分:使用 pandas 清理和探索数据

在这一部分中,我们将把 pandas,Python 强大的程序化电子表格,用于清理和探索数据集的问题。我们首先看到 pandas 是 Python 的 NumPy 生态系统的一部分,它利用了非常快速、功能强大的低级数组处理库的强大功能,同时使其易于访问。重点是使用 pandas 清理然后探索我们的诺贝尔奖数据集:

  • 大多数数据,即使来自官方的 Web API,也是脏的。使其变得干净和可用将占据您作为数据可视化者的时间,这可能比您预期的要多得多。以诺贝尔数据集为例,我们逐步清理它,寻找可疑的日期、异常的数据类型、缺失的字段以及所有需要在您开始探索和转换数据为可视化之前清理的常见污垢。

  • 有了我们手头的干净(尽可能的)诺贝尔奖数据集,我们看到使用 pandas 和 Matplotlib 交互式地探索数据是多么容易,轻松创建内联图表,以各种方式切片数据,并一般地了解数据,同时寻找您想通过可视化呈现的那些有趣的信息。

第四部分:交付数据

在这部分中,我们看到使用 Flask 创建最小数据 API 是多么简单,以便静态地和动态地将数据传递到 Web 浏览器:

首先,我们看到如何使用 Flask 提供静态文件,然后如何自己制作基本数据 API,从本地数据库中提供数据。Flask 的简洁性使您能够在 Python 数据处理结果和它们最终在浏览器上可视化之间创建一个非常薄的数据服务层。

开源软件的优点在于,你通常可以找到比你自己解决问题更好的、易于使用的库。在本部分的第二章中,我们看到如何使用 Python 的最佳库(如 Flask)轻松创建强大、灵活的 RESTful API,准备好在网上提供你的数据。我们还介绍了如何使用 Python 爱好者喜爱的 Heroku 轻松在线部署这个数据服务器。

第五部分:使用 D3 和 Plotly 可视化您的数据

在本部分的第一章中,我们看到如何将你通过 pandas 驱动的探索所得的成果(如图表或地图)放在它们应该呆的地方——网页上。Matplotlib 可以生成符合出版标准的静态图表,而 Plotly 则为用户提供了控制和动态图表的功能。我们看到如何直接从 Jupyter 笔记本中获取 Plotly 图表并将其放入网页中。

本书涵盖 D3 的部分是最具挑战性的,但你很可能最终会被雇佣来构建它所产生的多元素可视化。D3 的乐趣之一在于可以轻松在网上找到大量示例,但其中大多数示范了单一技术,很少展示如何编排多个视觉元素。在这些 D3 章节中,我们看到如何在用户过滤诺贝尔奖数据集或更改获奖指标(绝对或人均)时,同步更新时间线(显示所有诺贝尔奖)、地图、条形图和列表。

掌握这些章节中展示的核心主题应该能让你释放想象力,并通过实践学习。我建议选择一些与你心中有关的数据,并围绕它设计一个 D3 的作品。

第二版

当 O’Reilly 给我提供了撰写本书第二版的机会时,我有些犹豫。第一版的规模超出了预期,更新和扩展它可能需要大量工作。然而,经过审视所涉及的库的现状以及 Python 和 JavaScript 数据可视化生态系统的变化后,明显大部分使用的库(如 Scrapy、NumPy、pandas)仍然是可靠的选择,只需要进行较小的更新。

D3 是变化最大的库,但这些变化使 D3 更易于使用和教学。JavaScript 模块也牢固地存在,使代码更清晰,更符合 Python 爱好者的习惯。

有几个 Python 库似乎不再是可靠的选择,其中一些已被弃用。第一版书中对 MongoDB,一个 NoSQL 数据库进行了相当广泛的处理。我现在认为,传统的 SQL 更适合数据可视化工作,而最小的基于文件的、无服务器的 SQLite 如果需要数据库,则代表了数据可视化的一个甜蜜点。

不打算用另一个 Python 库替换已弃用的 RESTful 数据服务器,我认为从头开始构建一个简单的服务器会更有教育意义,演示一些出色的 Python 库的使用,比如 marshmallow,这些库在许多数据可视化场景中非常有用。

利用更新书籍的时间,我决定使用第一版书中的数据集,演示如何使用 Matplotlib 和 pandas 进行探索和分析,并专注于将所有库更新到它们当前(截至 2022 年中)的版本。这使得有时间专注于新材料,其中最重要的是一章专门介绍 Python 的 Plotly 库,该库允许您轻松将探索性工作从 Jupyter 笔记本转移到具有用户交互的 Web 演示中。这种方法的一个特别优势是 Mapbox 地图的可用性,这是一个丰富的地图生态系统。

第二版的主要目标是:

  • 使所有库保持最新。

  • 以删除和/或替换未经时间考验的库。

  • 通过 Python 和 JavaScript 数据可视化快速发展的世界中的变化建议添加一些新材料。

我认为数据可视化工具链的隐喻仍然适用,并且从未处理的原始网络数据到探索性数据可视化驱动的分析再到精美的网络可视化的转换流水线,仍然是学习这项工作的关键工具的良好方式。

本书使用的约定

本书使用以下排版约定:

斜体

指示新术语、网址、电子邮件地址、文件名和文件扩展名。

Constant width

用于程序列表,以及段落内引用程序元素,如变量或函数名、数据库、数据类型、环境变量、语句和关键字。

Constant width bold

显示用户应按字面意义输入的命令或其他文本。

Constant width italic

显示应由用户提供的值或由上下文确定的值替换的文本。

提示

此元素表示提示或建议。

注意

此元素表示一般说明。

警告

此元素表示警告或注意。

使用代码示例

补充材料(代码示例、练习等)可从https://github.com/Kyrand/dataviz-with-python-and-js-ed-2下载。

这本书旨在帮助您完成工作。一般来说,如果此书提供示例代码,您可以在自己的程序和文档中使用它。除非您复制了大部分代码,否则无需征得我们的许可。例如,编写一个使用此书中多个代码片段的程序不需要许可。售卖或分发包含 O'Reilly 书籍示例的 CD-ROM 需要许可。引用本书并引用示例代码回答问题不需要许可。将此书的大量示例代码整合到您产品的文档中需要许可。

我们感谢您的赞誉,但并不要求署名。典型的署名通常包括标题、作者、出版商和 ISBN。例如:“使用 Python 和 JavaScript 进行数据可视化,第二版,作者 Kyran Dale(O'Reilly 出版)。版权所有 2023 年 Kyran Dale Limited,978-1-098-11187-8。”

如果您认为您使用的示例代码超出了合理使用或上述许可,请随时联系我们permissions@oreilly.com

O'Reilly 在线学习

注意

40 多年来,O'Reilly Media提供技术和商业培训、知识和见解,帮助公司取得成功。

我们独特的专家和创新者网络通过书籍、文章以及我们的在线学习平台分享他们的知识和专长。O'Reilly 的在线学习平台为您提供按需访问的现场培训课程、深入学习路径、交互式编码环境,以及来自 O'Reilly 和 200 多个其他出版商的大量文本和视频。有关更多信息,请访问https://oreilly.com

如何联系我们

请将有关此书的评论和问题发送至出版商:

  • O'Reilly Media, Inc.

  • 1005 Gravenstein Highway North

  • Sebastopol, CA 95472

  • 800-998-9938(美国或加拿大)

  • 707-829-0515(国际或本地)

  • 707-829-0104(传真)

我们为这本书创建了一个网页,列出勘误、示例和任何额外信息。您可以访问https://oreil.ly/dvpj_2e

发送电子邮件至bookquestions@oreilly.com,以评论或询问有关本书的技术问题。

有关我们的书籍和课程的新闻和信息,请访问https://oreilly.com

在 LinkedIn 上找到我们:https://linkedin.com/company/oreilly-media

在 Twitter 上关注我们:https://twitter.com/oreillymedia

在 YouTube 上观看我们:https://www.youtube.com/oreillymedia

致谢

首先要感谢 Meghan Blanchette,她开始推动这本书的进展,并在最初的艰难章节中起到了重要作用。接下来是 Dawn Schanafelt 掌舵并完成了大部分必要的编辑工作。Kristen Brown 在出版过程中表现出色,得益于 Gillian McGarvey 顽强的剪辑工作。与如此才华横溢、敬业的专业人士一起工作是一种荣誉和特权——也是一种教育:如果当时我知道现在所知道的,写这本书就会容易得多。这不是一直都这样吗?

非常感谢 Amy Zielinski 使作者看起来比他应得的更好。

本书受益于一些非常有帮助的反馈。非常感谢 Christophe Viau、Tom Parslow、Peter Cook、Ian Macinnes 和 Ian Ozsvald。

在撰写本书初期发布时,我还要感谢那些勇敢的缺陷猎手们。截至目前,他们是 Douglas Kelley、Pavel Suk、Brigham Hausman、Marco Hemken、Noble Kennamer、Manfredi Biasutti、Matthew Maldonado 和 Geert Bauwens。

第二版

主要的感谢必须归功于 Shira Evans,她从构思到实现都给予了本书极大的帮助。Gregory Hyman 在及早发布时给予了我及时的反馈和帮助。再次,我很幸运有 Kristen Brown 完成本书的出版工作。

我也要感谢我的技术审阅者 Jordan Goldmeier、Drew Winstel 和 Jess Males 提供的宝贵建议。

^(1) 基于ECMAScript有许多 JavaScript 版本,但最重要的版本是ECMAScript 6,它提供了大部分新功能。

第一章:简介

这本书的目标是让你快速掌握我认为最强大的数据可视化堆栈:Python 和 JavaScript。你将学习足够的关于像 pandas 和 D3 这样的大型库,开始打造自己的网络数据可视化并完善自己的工具链。专业知识将随着实践而来,但是这本书提供了一个基础能力的浅学习曲线。

注意

如果你正在阅读这本书,我很乐意听取你的任何反馈意见。请发送至 pyjsdataviz@kyrandale.com。非常感谢。

你可以在我的网站找到诺贝尔奖可视化的工作副本,这本书在字面和象征意义上都是在构建这一可视化。

这本书的大部分内容讲述了无数数据可视化故事之一,精心选择以展示一些强大的 Python 和 JavaScript 库和工具,这些库和工具一起形成了一个工具链。这个工具链在其起点收集原始未加工数据,在其终点提供丰富而引人入胜的网络可视化。像所有的数据可视化故事一样,这是一个转变的故事——在这种情况下,将一个基本的维基百科诺贝尔奖获奖者列表转化为互动可视化,使得探索奖项历史变得轻松而有趣。

无论你有什么样的数据,想用它讲述什么样的故事,将其转化为可视化后的自然家园就是网络。作为一个交付平台,它比以往任何一个平台都更加强大,而这本书旨在使得从基于桌面或服务器的数据分析和处理过渡到网络变得更加顺畅。

使用这两种强大语言不仅能够提供强大的网络可视化,而且还充满乐趣和吸引力。

许多潜在的数据可视化程序员认为网页开发和他们想做的事情之间存在很大的分歧,他们想做的是使用 Python 和 JavaScript 进行编程。网页开发涉及大量关于标记语言、样式脚本和管理的深奥知识,并且不能没有像WebpackGulp这样奇怪命名的工具。如今,这种巨大分歧可以被缩小到一层薄薄的、非常可渗透的膜,使你能够专注于你擅长的事情:用最少的功夫编程(见图 I-1),将网页服务器降低到数据交付的级别。

dpj2 01

图 I-1. 这里是网页开发的巨龙

本书适合读者

首先,这本书适合所有对 Python 或 JavaScript 有合理掌握的人,他们希望探索当前数据处理生态系统中最令人兴奋的领域之一:数据可视化的爆炸性增长。同时,它也解决了一些根据我的经验相当普遍的具体挑战。

当你被委托写技术书籍时,编辑通常会明智地提醒你,要考虑你的书能解决的痛点。这本书的两个关键痛点最好通过一些故事来说明,包括我自己的故事和一些 JavaScript 开发者向我讲述的故事的各种变体。

多年前,作为一名学术研究员,我接触到 Python 并爱上了它。我之前在 C中写了一些相当复杂的模拟程序,Python 的简洁和强大为我带来了一股清新的空气,远离了所有样板文件、声明、定义等等。编程变得有趣起来。Python 是完美的粘合剂,与我的 C库良好配合(当时 Python 并不是速度飞快的语言,现在也不是),并且毫不费力地执行低级语言中让人头疼的所有操作(例如文件 I/O、数据库访问和序列化)。我开始用 Python 编写所有的图形用户界面(GUI)和可视化工具,使用 wxPython、PyQt 以及其他许多简单易用的工具集。不幸的是,尽管其中一些工具非常酷,我很想与世界分享,但需要打包、分发并确保它们仍然与现代库兼容的努力代表了我难以克服的障碍。

那时候,有一个在理论上是完美的软件通用分发系统存在,那就是网页浏览器。网页浏览器几乎在地球上每台计算机上都可以找到,并且具有自己的内置解释型编程语言:一次编写,到处运行。但是 Python 不能在网页浏览器的沙盒中运行,而且浏览器对于宏大的图形和可视化是无能为力的,基本上只能处理静态图像和偶尔的jQuery 转换。JavaScript 是一种“玩具”语言,与非常慢的解释器绑定,只适合做一些DOM小技巧,但绝对无法与我在桌面上使用 Python 所能做的相提并论。因此,这条路线被毫不留情地排除了。我的可视化想要呈现在网络上,但却没有办法去实现它。

十年左右过去了,多亏了由 Google 及其 V8 引擎发起的竞争激化,JavaScript 现在的速度已经快了几个数量级;事实上,它现在比 Python 快得多。^(1) HTML 也通过 HTML5 的形式稍作整理。现在使用起来更加舒适,减少了大量样板代码。像可伸缩矢量图形(SVG)这样宽松跟进、不太稳定的协议已经得到了很好的加固,得益于强大的可视化库,尤其是 D3。现代浏览器被要求与 SVG 和越来越多的 3D 技术如 WebGL 及其衍生库(如 THREE.js)良好协作。我以前在 Python 中做的可视化现在可以在你的本地网页浏览器上实现,而且最大的好处是,只需很少的努力,就能让它们适用于全世界的每台台式机、笔记本电脑、智能手机和平板电脑。

那么,为什么 Python 爱好者们不纷纷将他们的数据以他们指定的形式发布出去呢?毕竟,除了自己精心制作之外,别无选择,这对我认识的大多数数据科学家来说都远非理想。首先,有那个术语web development,暗示着复杂的标记、难以理解的样式表,以及一大堆新工具需要学习,需要掌握的 IDE。然后是 JavaScript 本身,这是一种奇怪的语言,直到最近被认为不过是一种玩具,而且在某种程度上难以归类。我打算直面这些痛点,并展示,你可以使用极少量的 HTML 和 CSS 样板代码制作现代的 Web 可视化(通常是单页应用程序),让你专注于编程,而 JavaScript 对于 Python 爱好者来说并不难上手。但你并不需要跳跃;第二章 是一个语言桥梁,旨在帮助 Python 爱好者和 JavaScript 开发者通过突出共同元素和提供简单的转换来弥合语言之间的鸿沟。

第二个故事在我认识的 JavaScript 数据可视化者中很普遍。在 JavaScript 中处理数据远非理想。虽然语言最近的功能增强使得数据整理变得更加愉快,但仍然没有真正的数据处理生态系统可言。因此,现有的强大可视化库(D3 仍然是最重要的库)和浏览器提供的清洁和处理任何传输到浏览器的数据的能力之间存在明显的不对称。所有这些都要求你在另一种语言中进行数据清洗、处理和探索,或者使用像 Tableau 这样的工具包,这通常会演变为零散的探索,模糊记忆的 Matlab,陡峭学习曲线的 R,或者一两个 Java 库。

Tableau这样的工具包尽管非常令人印象深刻,但对程序员来说常常令人沮丧。在 GUI 中无法复制一个好的、通用编程语言的表现力。此外,如果您想创建一个小型的 Web 服务器来传递处理过的数据,那就意味着至少要学习一种新的 Web 开发能力语言。

换句话说,开始拓展其数据可视化的 JavaScript 开发者,正在寻找一个需要时间投入最少、学习曲线最浅的互补数据处理栈。

使用本书的最低要求

我总是对限制人们探索的行为感到不情愿,特别是在编程和网络的背景下,充斥着自学者(否则人们如何在学术界落后于潮流的情况下学习呢?),他们快速而狂热地学习,光彩夺目地不受曾经适用于学习的正式限制的约束。从编程语言的角度来看,Python 和 JavaScript 几乎是最简单的,并且都是最佳首选语言的主要候选者。在解释代码时,没有太大的认知负荷。

按照这种精神,有些专家程序员,即使没有 Python 和 JavaScript 的任何经验,也可以在一周内消化这本书,并且能够编写自定义库。

对于初学者程序员来说,对 Python 或 JavaScript 新手来说,这本书可能太高级了,我建议您利用当前大量的书籍、网络资源、录屏和其他资源,这些资源使学习变得如此简单。专注于个人的兴趣点,一个您想解决的问题,并通过实践学习编程——这是唯一的方式。

对于那些已经在 Python 或 JavaScript 中编程过一点的人,我建议入门的门槛是您已经一起使用了几个库,了解了您语言的基本习惯,并且能够看一段新代码,并大致了解其中的运行机制——换句话说,可以使用标准库的几个模块的 Python 开发者,以及已经使用过一些库并理解其源代码几行的 JavaScript 开发者。

为什么选择 Python 和 JavaScript?

为什么选择 JavaScript 是一个容易回答的问题。在现在和可预见的未来,只有一种一流的、基于浏览器的编程语言。虽然曾经有过各种扩展、增强和篡改的尝试,但好旧的、普通的 JavaScript 仍然是首屈一指的。如果您想制作现代、动态、交互式的可视化,并且在点击按钮时将它们传递给世界,那么迟早您会遇到 JavaScript。您可能不需要掌握它,但基本的能力是进入现代数据科学中最激动人心的领域的基本门槛。这本书将带您进入这个领域。

为什么不在浏览器中使用 Python?

最近有一些倡议旨在在浏览器中运行 Python 的有限版本。例如,Pyodide是将 CPython 移植到 WebAssembly 的一个项目。这些项目令人印象深刻且有趣,但目前在 Python 中生成网页图表的主要方式是通过中间库自动转换它们。

目前有一些非常令人印象深刻的倡议,旨在使 Python 生成的可视化效果能够在浏览器中运行。它们通过将 Python 代码转换为基于canvassvg绘图上下文的 JavaScript 来实现。其中最受欢迎和成熟的是PlotlyBokeh。在第十四章中,您将看到如何在 Jupyter 笔记本中使用 Plotly 生成图表,并将其转移到网页上。对于许多用例来说,这是一个很好的数据可视化工具。

虽然这些 JavaScript 转换器背后有一些精彩的编码和许多实际用例,但它们确实有很大的局限性:

  • 自动代码转换可能能完成任务,但生成的代码通常对人类来说很难理解。

  • 使用强大的基于浏览器的 JavaScript 开发环境调整和定制生成的图表可能会很痛苦。在第十四章中,我们将看到如何通过使用 Plotly 的 JS API 来减轻这种痛苦。

  • 您目前只能使用这些库中当前可用的图表类型的子集。

  • 当前的交互性非常基础。最好使用浏览器的开发工具,在 JavaScript 中定制用户控件。

请记住,构建这些库的人必须是 JavaScript 专家,因此如果您希望理解他们正在做的事情并最终表达自己,那么您必须学习一些 JavaScript。

为什么选择 Python 进行数据处理

关于数据处理,选择 Python 的原因更加复杂一些。首先,在数据处理方面有很多良好的替代方案。让我们从企业巨头 Java 开始,逐一探讨一些候选方案。

Java

在其他主要的通用编程语言中,只有 Java 提供了类似 Python 丰富库生态系统的东西,并且速度更快。但是虽然 Java 比如 C之类的语言更容易编程,但在我看来,它并不是一种特别好的编程语言,因为它有太多的乏味样板代码和过度冗长的言辞。这种事情在一段时间后开始变得沉重,并使编程变得非常困难。至于速度,Python 的默认解释器速度较慢,但 Python 是一种很好的“胶水”语言,可以与其他语言很好地协作。这种能力由大型 Python 数据处理库如 NumPy(及其依赖项 pandas)、SciPy 等所展示,它们使用 C和 Fortran 库来进行重型计算,同时提供简单脚本语言的易用性。

R

备受尊敬的 R 直到最近一直是许多数据科学家的首选工具,也许是 Python 在这一领域的主要竞争对手。像 Python 一样,R 受益于一个非常活跃的社区,一些很棒的工具如绘图库 ggplot2,以及专门为数据科学和统计学设计的语法。但这种专业性是一把双刃剑。因为 R 是为特定目的开发的,这意味着如果你想写一个用于提供 R 处理数据的 Web 服务器,你必须跳出去使用另一种语言,带来了所有相关的学习开销,或者尝试以一种圆孔/方钉的方式粗略地组合。Python 的通用性质和其丰富的生态系统意味着可以在不离开其舒适区的情况下完成几乎所有数据处理流水线所需的一切(除了 JS 视觉)。对我个人而言,这是为了一点点语法笨拙而付出的小小牺牲。

其他

有其他选择可以用 Python 进行数据处理,但它们都无法与一种通用、易于使用的编程语言及其丰富的库生态系统所带来的灵活性和能力相比。例如,像 Matlab 和 Mathematica 这样的数学编程环境有着活跃的社区和大量优秀的库,但它们几乎不能算作通用目的,因为它们设计用于在封闭的环境中使用。它们也是专有的,这意味着需要显著的初始投资,并且与 Python 显然开放源代码的环境不同。

Tableau这样由 GUI 驱动的数据可视化工具是伟大的创造,但对于习惯了编程自由的人来说,它们很快会感到沮丧。只要你按照它们的乐谱唱歌,它们通常运行良好。一旦偏离指定路径,情况就会迅速恶化。

Python 的日益改进

就目前情况而言,我认为可以充分证明 Python 是新兴数据科学家的首选语言。但事情并未停滞不前;事实上,Python 在这一领域的能力正以惊人的速度增长。举个例子,我已经使用 Python 编程超过 20 年了,并且习惯于惊讶于如果找不到一个 Python 模块来帮助解决手头的问题,但我发现 Python 在数据处理能力方面的增长让我感到惊讶,每周都会出现一个新的强大库。例如,Python 传统上在统计分析库方面表现较弱,而 R 则遥遥领先。最近,一些强大的模块,如 statsmodels,开始迅速弥补这一差距。

Python 是一个繁荣的数据处理生态系统,几乎无与伦比的通用性,并且它每周都在变得更好。可以理解为什么社区中有这么多人处于如此兴奋的状态——这确实令人振奋。

至于在浏览器中的可视化,好消息是 JavaScript 并不仅限于它在网络生态系统中的特权,相反,由于解释器的竞争战,性能大幅提升,以及一些强大的可视化库,比如 D3,这些都能与任何语言完美配合,JavaScript 现在拥有了真正的实力。

简而言之,Python 和 JavaScript 是网络数据可视化的绝佳补充,彼此需要对方来提供至关重要的缺失组件。

你将学到什么

在我们的数据可视化工具链中,有一些重要的 Python 和 JavaScript 库,要全面覆盖它们需要多本书。然而,我认为大多数库的基础知识,尤其是本书涵盖的那些,可以相当快地掌握。专业知识需要时间和实践,但要成为高效工作所需的基础知识,可以说是“易如反掌”。

从这个意义上说,本书旨在为您提供坚实的实用知识基础,足以支撑未来发展的重任。我旨在尽可能地降低学习曲线,并通过实际技能帮助您克服最初的障碍,开始精炼您的技艺。

本书强调实用主义和最佳实践。它将涵盖大量内容,没有足够的空间进行许多理论上的分歧。我涵盖了工具链中最常用的库的方面,并指引您去了解其他问题的资源。大多数库都有一些核心的函数、方法、类等,这些是主要的、功能性的子集。有了这些工具,您实际上可以做一些事情。最终,您会发现有些问题您无法仅凭这些工具解决,这时好书、文档和在线论坛将成为您的朋友。

库的选择

在选择本书中使用的库时,我有三个考虑因素:

  • 开源和 免费(如啤酒一样) —— 您无需额外投资任何费用就可以使用本书进行学习。

  • 历史悠久 —— 通常是经过良好建立、由社区驱动和流行的。

  • 假设有良好的支持和活跃的社区,最佳实践处于流行度和实用性的甜蜜点。

您在此学到的技能应该会长期保持相关性。通常情况下,已经选择了明显的候选库——这些库可以自行编写它们的票据。在适当的情况下,我将突出显示替代选择,并对我选择的理由进行说明。

开篇

在我们的诺贝尔奖数据集通过工具链进行转换之旅开始之前,需要一些初步章节。这些章节涵盖了使得后续工具链章节更流畅运行所需的基本技能。前几章包括以下内容:

第二章,“Python 和 JavaScript 之间的语言学习桥梁”

搭建 Python 和 JavaScript 之间的语言桥梁

第三章,“使用 Python 读写数据”

如何通过 Python 在各种文件格式和数据库中传递数据

第四章,“Webdev 101”

涵盖本书所需的基本网页开发

这些章节既是教程又是参考,可以直接跳到工具链的开始部分,需要时再回头查看。

数据可视化工具链

本书的主要部分展示了数据可视化工具链,跟随诺贝尔奖获得者数据集从原始的、刚刮取下来的数据到引人入胜的、交互式的 JavaScript 可视化的过程。在收集过程中,展示了多个大型库的精炼和转换,总结在 图 I-2 中。这些库是我们工具链的工业车床:丰富、成熟的工具展示了 Python+JavaScript 数据可视化堆栈的力量。接下来的章节包含了对我们工具链五个阶段及其主要库的简要介绍。

dpj2 02

图 I-2. 数据可视化工具链

1. 使用 Scrapy 抓取数据

任何数据可视化工作者的第一个挑战是获得他们需要的数据,无论是受到请求的启发还是为了解决个人问题。如果非常幸运,这些数据会以原始形式交付给您,但更多时候您需要自己去寻找。我将介绍您可以使用 Python 从网络获取数据的各种方法(例如,web API 或 Google 电子表格)。用于工具链演示的诺贝尔奖数据集是通过使用 Scrapy 从维基百科页面抓取的。^(2)

Python 的 Scrapy 是一个工业强度的爬虫程序,可以处理所有的数据节流和媒体管道,如果您计划抓取大量数据,这些功能是不可或缺的。抓取数据通常是获取您感兴趣数据的唯一途径,一旦掌握了 Scrapy 的工作流程,之前无法访问的数据集只隔一只蜘蛛之遥。^(3)

2. 使用 pandas 清洗数据

数据可视化的不为人知的秘密是,几乎所有的数据都是不干净的,将其转化为可用的数据可能会比预期花费更多时间。这是一个不起眼的过程,很容易占用超过一半的时间,这也是掌握它并使用正确工具的更多理由。

pandas 在 Python 数据处理生态系统中扮演着重要角色。它是一个 Python 数据分析库,其主要组件是 DataFrame,本质上是一个编程化的电子表格。pandas 将 NumPy(Python 强大的数值计算库)扩展到了异构数据集的领域,这些数据集包括分类、时间序列和顺序信息,这些是数据可视化者必须处理的类型。

除了适合交互式探索数据(使用其内置的 Matplotlib 绘图),pandas 还非常适合清理数据的苦活,可以轻松定位重复记录,修复不规范的日期字符串,找到丢失的字段等等。

3. 使用 pandas 和 Matplotlib 探索数据

在开始将数据转换为可视化之前,您需要对数据有深入的理解。数据中隐藏的模式、趋势和异常将决定您试图通过数据传达的故事,无论是解释年度小部件销售的最近增长还是展示全球气候变化。

IPython一起,作为强化版的 Python 解释器,pandas 和 Matplotlib(包括 seaborn 等附加组件)提供了一种很好的交互式数据探索方式,可以从命令行生成丰富的内联图表,通过切片和切块数据来揭示有趣的模式。这些探索的结果可以轻松保存到文件或数据库,供 JavaScript 可视化使用。

4. 使用 Flask 传递您的数据

一旦您探索和完善了您的数据,您将需要将其提供给 Web 浏览器,其中像 D3 这样的 JavaScript 库可以转换它。使用像 Python 这样的通用语言的一个伟大优势是,它像通过几行代码滚动一个 Web 服务器一样轻松,它像使用 NumPy 和 SciPy 这样的特定目的库一样轻松处理大型数据集。(4) Flask 是 Python 最流行的轻量级服务器,非常适合创建小型的、RESTful(5) API,JavaScript 可以使用它从服务器、文件或数据库中获取数据到浏览器。正如我将演示的那样,您可以用几行代码来创建一个 RESTful API,能够从 SQL 或 NoSQL 数据库中提供数据。

5. 使用 Plotly 和 D3 将数据转换为交互式可视化

数据清洗和精炼完成后,我们进入可视化阶段,展示数据集的精选反映,可能允许用户进行交互式探索。根据数据的不同,这可能涉及传统图表、地图或新颖的可视化方式。

Plotly 是一个出色的图表库,允许您在 Python 中开发图表并将其转移到 Web 上。正如我们将在第十四章中看到的那样,它还具有一个 JavaScript API,模仿 Python API,为您提供一个免费的本地 JS 图表库。

D3 是 JavaScript 强大的可视化库,无论语言如何,都是最强大的可视化工具之一。我们将使用 D3 创建一个包含多个元素和用户交互的新颖的诺贝尔奖可视化,允许人们探索数据集中的感兴趣项。学习 D3 可能具有挑战性,但我将迅速带您了解并准备好开始磨练您的技能。

更小的库

除了涵盖的大型库外,还有一个大量的小型库支持群体。这些是不可或缺的小工具,是工具链的锤子和扳手。特别值得一提的是 Python 生态系统非常丰富,几乎为每个可想象的任务提供了小型专业库。在强大的支持阵容中,特别值得一提的是:

Requests

Python 的首选 HTTP 库,完全配得上其口号“人类的 HTTP”。Requests 比 Python 自带的 urllib2 要好得多。

SQLAlchemy

最好的 Python SQL 工具包和对象关系映射器(ORM)。它功能丰富,使得与各种基于 SQL 的数据库的工作相对轻松。

seaborn

作为 Python 绘图强大工具 Matplotlib 的重要补充,它添加了一些非常有用的图形类型,特别适用于数据可视化者。它还增加了更出色的美学效果,覆盖了 Matplotlib 的默认设置。

Crossfilter

尽管 JavaScript 的数据处理库还在不断发展中,但最近出现了一些非常有用的库,其中 Crossfilter 是一个突出的例子。它能够非常快速地对行列数据集进行过滤,非常适合数据可视化工作,这一点并不令人意外,因为它的创始人之一是 D3 的创始人 Mike Bostock。

marshmallow

一个非常出色且非常方便的库,可以将诸如对象之类的复杂数据类型转换为原生 Python 数据类型。

使用本书

虽然本书的不同部分遵循数据转换的过程,但不必从头到尾一次性阅读。第一部分提供了一个基本工具包,用于基于 Python 和 JavaScript 的 web 数据可视化,并且其中的内容对许多读者来说可能是熟悉的。挑选你不了解的内容,并根据需要进行回溯(根据需要将会有链接回溯)。对于那些精通这两种语言的人来说,Python 和 JavaScript 之间的语言学习桥梁是不必要的,尽管仍然可能有一些有用的信息。

本书的其余部分遵循我们的工具链,将一个相当乏味的 web 列表转化为一个完全成熟、交互式的 D3 可视化,本质上是相互独立的。如果你想立即深入到 第三部分 并使用 pandas 进行一些数据清理和探索,可以直接进行,但要注意它假定存在一个脏的诺贝尔奖数据集。如果这符合你的计划,稍后可以看看 Scrapy 是如何产生它的。同样,如果你想直接开始创建诺贝尔可视化应用的第四部分和第五部分,请注意它们假设有一个干净的诺贝尔奖数据集。

无论选择哪条路径,我建议最终目标是掌握书中涵盖的所有基本技能,如果你打算把数据可视化作为你的职业。

一点背景信息

这是一本实用的书,假设读者对自己想要可视化的内容有一个相当清晰的想法,以及这种可视化应该看起来和感觉如何,并且渴望开始行动,不受太多理论的约束。然而,借鉴数据可视化的历史既可以澄清本书的中心主题,也可以增加宝贵的背景知识。它还可以帮助解释为什么现在是进入这个领域的如此激动人心的时刻,因为技术创新正在推动新型数据可视化形式的发展,人们正在努力解决因互联网生成的多维数据量不断增加而带来的问题。

数据可视化背后有着一整套令人印象深刻的理论体系,而且有一些我建议你阅读的好书(参见“推荐书籍”)。理解人类视觉收集信息的方式的实际好处不言而喻。例如,通过心理测量实验,我们现在已经相当清楚如何欺骗人类视觉系统,使数据中的关系更难理解。相反,我们可以展示一些视觉形式几乎最优化地增强对比度。至少从文献上来看,这些提供了一些有用的经验法则,建议了解任何特定数据叙事的良好候选人。

本质上,良好的数据可视化试图以一种能够揭示或强调可能存在的任何模式或趋势的方式呈现数据,这些数据可能是从世界上的测量(经验性)中收集的,或者作为抽象数学探索的产物(例如,Mandelbrot 集的美丽分形图案)。这些模式可以是简单的(例如,按国家平均体重),也可以是复杂统计分析的产物(例如,在更高维度空间中的数据聚类)。

在其未转换的状态下,我们可以想象这些数据漂浮为一个模糊的数字或类别云团。任何模式或相关性都完全隐晦。很容易忘记,但是卑微的电子表格(图 I-3 a)就是数据可视化——数据排列成行列形式是为了驯服它,使其操作更容易,并突出差异(例如,精算簿记)。当然,大多数人并不擅长在数字行中发现模式,因此开发了更易于理解的视觉形式来与我们的视觉皮层互动,这是人类获取关于世界信息的主要通道。于是便有了条形图、饼图^(6)和折线图的出现。更富想象力的方法被用来将统计数据提炼成更易理解的形式,其中最著名的之一是查尔斯·约瑟夫·米纳德对 1812 年拿破仑灾难性俄罗斯战役的可视化(图 I-3 b)。

图 I-3 中的浅棕色流表示拿破仑军队向莫斯科进发;黑线表示撤退。流的粗细代表拿破仑军队规模,随伤亡增加而变薄。下方的温度图表用于指示沿途位置的温度。请注意 Minard 如何巧妙地结合多维数据(伤亡统计、地理位置和温度),以展现战争的惨状,这在其他方式下很难理解(想象试图从伤亡图表跳转到位置列表并进行必要的联系)。我认为现代交互式数据可视化的主要问题与 Minard 面临的问题完全相同:如何超越传统的一维条形图(对于许多事情来说完全有效)并开发新的有效传达跨维度模式的方式。

dpj2 03

图 I-3. (a) 早期电子表格和 (b) Charles Joseph Minard 对 1812 年拿破仑俄罗斯战役的可视化

直到最近,我们对图表的大部分体验与 Charles Joseph Minard 的观众并无多大不同。它们都是预渲染和惰性的,展示的只是数据的一个反映,希望是一个重要且有洞察力的反映,但仍然完全受作者控制。在这个意义上,将真实的墨水点替换为计算机屏幕像素只是分布规模的改变。

互联网的发展只是用像素取代了新闻纸,可视化仍然是不可点击且静态的。最近,一些强大的可视化库(其中以 D3 为首)和 JavaScript 性能的大幅提升开辟了一条新的可视化路径,这种可视化方式易于访问和动态,并实际上鼓励探索和发现。数据探索和展示之间的明确界限变得模糊。这种新型数据可视化是本书的重点,也是为什么 Web 数据可视化现在如此令人兴奋的原因。人们正在尝试创造新的数据可视化方式,使其对最终用户更加可访问和有用。这简直是一场革命。

摘要

现在,数据可视化在网络上是一个令人兴奋的领域,交互式可视化的创新层出不穷,许多(如果不是大多数)都是用 D3 开发的。 JavaScript 是唯一的基于浏览器的语言,因此酷炫的视觉效果必然是用它(或转换为它)。但是 JavaScript 缺乏进行数据聚合、筛选和处理的工具或环境,这是现代数据可视化同样重要但不那么引人注目的部分。这就是 Python 的优势所在,它提供了一种通用、简洁和极易阅读的编程语言,并且可以访问日益稳定的一流数据处理工具。其中许多工具利用了非常快速的低级别库的强大功能,使 Python 数据处理既快速又简单。

本书介绍了一些重量级工具,以及一系列其他较小但同样重要的工具。它还展示了 Python 和 JavaScript 结合使用是最佳数据可视化堆栈,任何希望将他们的可视化作品发布到互联网上的人都可以使用。

接下来是本书的第一部分,涵盖了工具链所需的初步技能。您现在可以开始学习,或者直接跳到 第 II 部分 和工具链的开始部分,在需要时参考之前的内容。

推荐书籍

以下是一些关键的数据可视化书籍,以满足您的胃口,涵盖从交互式仪表板到美丽而富有洞见的信息图表的方方面面。

  • Bertin, Jacques. Semiology of Graphics: Diagrams, Networks, Maps. Esri Press, 2010.

  • Cairo, Alberto. The Functional Art. New Riders, 2012.

  • Few, Stephen. Information Dashboard Design: Displaying Data for At-a-Glance Monitoring, 2nd Ed. Analytics Press, 2013.

  • Rosenberg, Daniel and Anthony Grafton. Cartographies of Time: A History of the Timeline. Princeton Architectural Press, 2012.

  • Tufte, Edward. The Visual Display of Quantitative Information, 2nd Ed. Graphics Press, 2001.

  • Wexler, Steve. The Big Book of Dashboards. Wiley, 2017.

  • Wilke, Claus. Fundamentals of Data Visualization. O’Reilly, 2019. (免费在线版本.)

^(1) 参见 Benchmarks Game 网站 进行相当令人瞠目结舌的比较。

^(2) 网页抓取 是一种从网站提取信息的计算机软件技术,通常涉及获取和解析网页。

^(3) Scrapy 的控制器被称为爬虫。

^(4) 这是 NumPy 生态系统的一部分的科学 Python 库。

^(5) REST 是 Representational State Transfer 的缩写,是基于 HTTP 的 Web API 的主导风格,并且强烈推荐使用。

^(6) 威廉·普莱费尔于 1801 年创作的统计摘要因创造了饼图而获得了可疑的荣誉。

第一部分:基本工具包

这本书的第一部分为即将介绍的工具链提供了基本工具包,既是教程,也是参考资料。考虑到目标读者的广泛知识范围,可能会涵盖您已经了解的内容。我的建议是精选材料填补您知识上的空白,可能只是略读您已经熟悉的部分作为复习。

如果您确信自己已经掌握了基本的工具包,请随意跳到我们在第二部分沿着工具链开始旅程的起点。

提示

您可以在书的 GitHub 仓库找到本书这部分的代码。

第一章:开发设置

本章涵盖了下载和安装本书所需的软件,并概述了推荐的开发环境。正如你将看到的,这并不像过去那样繁琐。我将分别介绍 Python 和 JavaScript 的依赖关系,并简要概述跨语言 IDE。

附带的代码

本书涵盖的大部分代码都可以在 GitHub 仓库中找到,包括完整的诺贝尔奖可视化。要获取它,只需将其git clone到合适的本地目录:

$ git clone https://github.com/Kyrand/dataviz-with-python-and-js-ed-2.git

这将创建一个名为dataviz-with-python-and-js-v2的本地目录,并包含书中涵盖的关键源代码。

Python

本书涵盖的大部分库都是基于 Python 的,但是 Anaconda 的存在使得为各种操作系统及其怪癖提供全面安装说明的尝试变得更加容易,Anaconda 是一个 Python 平台,它将大多数流行的分析库捆绑在一个方便的软件包中。本书假设您正在使用自 2008 年发布以来已经稳固确立的 Python 3。

Anaconda

曾经安装一些较大的 Python 库本身就是一个挑战,特别是那些依赖于复杂低级 C 和 Fortran 包的库,如 NumPy。现在这变得简单得多,大多数可以使用 Python 的easy_installpip命令轻松安装:

$ pip install NumPy

但是某些大数据处理库仍然难以安装。依赖管理和版本控制(您可能需要在同一台机器上使用不同版本的 Python)可能会使事情变得更加复杂,这正是 Anaconda 发挥作用的地方。它执行所有的依赖检查和二进制安装,使您免于烦恼。对于像这样的书籍资源来说,这也非常方便。

要获取免费的 Anaconda 安装,请只需将浏览器导航到Anaconda 网站,选择适合您操作系统版本(理想情况下至少是 Python 3.5),然后按照说明操作。Windows 和 OS X 有图形安装程序(只需下载并双击),而 Linux 需要您运行一个小的 bash 脚本:

$ bash Anaconda3-2021.11-Linux-x86_64.sh

这是最新的安装说明:

我建议在安装 Anaconda 时保持默认设置。

可以在Anaconda 网站找到官方检查指南。Windows 和 macOS 用户可以使用 Anaconda 的 Navigator GUI,或者与 Linux 用户一起使用 Conda 命令行界面。

安装额外的库

Anaconda 包含了本书涵盖的几乎所有 Python 库(详见Anaconda 文档中的完整 Anaconda 库包列表)。在我们需要非 Anaconda 库时,可以使用pip(Pip Installs Python 的简称),这是安装 Python 库的事实标准。使用pip安装非常简单。只需在命令行中调用pip install,然后跟上包的名称,它就会被安装,或者出现一个合理的错误信息:

$ pip install dataset

虚拟环境

虚拟环境提供了一种使用特定的 Python 版本和/或第三方库集创建沙盒式开发环境的方式。使用这些虚拟环境可以避免在全局 Python 环境中安装这些软件包,并且给你更多的灵活性(你可以尝试不同的包版本或者改变 Python 版本)。在 Python 开发中,使用虚拟环境已经成为一种最佳实践,我强烈建议你遵循这个做法。

Anaconda 附带一个conda系统命令,使得创建和使用虚拟环境变得简单。让我们为本书创建一个特别的虚拟环境,基于完整的 Anaconda 包:

$ conda create --name pyjsviz anaconda
...
#
# To activate this environment, use:
# $ source activate pyjsviz
#
# To deactivate this environment, use:
# $ source deactivate
#

正如最后的消息所说,要使用这个虚拟环境,你只需source activate它(对于 Windows 机器,可以省略source):

$ source activate pyjsviz
discarding /home/kyran/anaconda/bin from PATH
prepending /home/kyran/.conda/envs/pyjsviz/bin to PATH
(pyjsviz) $

注意,在命令行中会得到一个有用的提示,让你知道正在使用哪个虚拟环境。

conda命令不仅仅能够简化虚拟环境的使用,还结合了 Python 的pip安装器和virtualenv命令的功能等。你可以在Anaconda 文档中获得完整的介绍。

如果你对标准的 Python 虚拟环境感到自信,它们已经通过将其整合到 Python 标准库中而变得更加易于使用。要从命令行创建一个虚拟环境:

$ python -m venv python-js-viz

这将创建一个python-js-viz目录,其中包含虚拟环境的各种元素。这包括一些激活脚本。要在 macOS 或 Linux 上激活虚拟环境,运行激活脚本:

$ source python-js-viz/bin/activate

在 Windows 机器上,运行*.bat*文件:

$ python-js-viz/Scripts/activate.bat

然后你可以使用pip来安装 Python 库到虚拟环境,避免污染全局 Python 分布:

$ (python-js-viz) pip install NumPy

要安装本书所需的所有库,可以使用书的GitHub 存储库中的requirements.txt文件:

$ (python-js-viz) pip install -r requirements.txt

你可以在 Python 文档的虚拟环境部分找到相关信息。

JavaScript

好消息是,你几乎不需要任何 JavaScript 软件。唯一必须的是在本书中使用的 Chrome/Chromium Web 浏览器。它提供了任何当前浏览器中最强大的一套开发工具,并且跨平台。

要下载 Chrome,只需访问主页,然后下载适合您操作系统版本的程序。这应该会自动检测到。

本书中使用的所有 JavaScript 库都可以在附带的GitHub 仓库中找到,但通常有两种方法将它们传递到浏览器。您可以使用内容交付网络(CDN),它有效地缓存从交付网络检索到的库的副本。或者,您可以使用本地库的副本传递到浏览器。这两种方法都使用 HTML 文档中的script标签。

内容交付网络

使用 CDN,而不是在本地机器上安装库,浏览器从最近的可用服务器上获取 JavaScript,从而使事情变得非常快速——比您自己提供内容更快。

要通过 CDN 包含库,您使用通常放置在 HTML 页面底部的常规<script>标签。例如,以下调用会添加当前版本的 D3:

<script
 src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.1.1/d3.min.js"
 charset="utf-8">
</script>

在本地安装库

如果需要在本地安装 JavaScript 库,例如预期进行离线开发工作或无法保证网络连接,则有许多非常简单的方法可以实现。

您只需下载单独的库并将其放入本地服务器的静态文件夹中即可。这是典型的文件夹结构。第三方库放在根目录下的static/libs目录中,如下所示:

nobel_viz/
└── static
    ├── css
    ├── data
    ├── libs
    │     └── d3.min.js
    └── js

如果您以这种方式组织事务,现在使用 D3 在您的脚本中需要使用<script>标签引用本地文件:

<script src="/static/libs/d3.min.js"></script>

数据库

推荐用于中小型数据可视化项目的建议数据库是非常出色的、无服务器、基于文件的 SQL 数据库SQLite。该数据库在书中展示的数据可视化工具链中被广泛使用,是您真正需要的唯一数据库。

这本书还涵盖了基本的 Python 与最受欢迎的非关系型数据库MongoDB的交互。

SQLite

SQLite 应该作为 macOS 和 Linux 机器的标准配备。对于 Windows,请参阅此指南

MongoDB

您可以在 MongoDB 文档中的各种操作系统的安装说明中找到安装说明

请注意,我们将直接或通过依赖它的库(例如SQLAlchemy SQL 库)使用 Python。这意味着我们可以通过更改一两行配置来将任何 SQLite 示例转换为其他 SQL 后端(例如MySQLPostgreSQL)。

启动和运行 MongoDB

MongoDB 的安装可能比某些数据库复杂一些。正如提到的那样,你可以完全不安装基于服务器的 MongoDB 而完美地使用本书,但如果你想尝试或者在工作中需要使用它,这里有一些安装注意事项:

对于 OS X 用户,请查阅官方文档获取 MongoDB 安装指南。

这篇来自官方文档的针对 Windows 的特定指南应该能帮助你启动 MongoDB 服务器。你可能需要使用管理员权限来创建必要的数据目录等。

如今,你更可能将 MongoDB 安装到基于 Linux 的服务器上,最常见的是 Ubuntu 变种,它使用deb文件格式来提供其软件包。官方 MongoDB 文档在涵盖 Ubuntu 安装方面做得很好。

MongoDB 使用一个数据目录来存储数据,并且,根据你的安装方式,你可能需要自己创建这个目录。在 OS X 和 Linux 系统上,默认是在根目录下的一个数据目录,你可以使用mkdir命令作为超级用户(sudo)创建它:

$ sudo mkdir /data
$ sudo mkdir /data/db

然后你需要设置所有权为自己:

$ sudo chown 'whoami' /data/db

在 Windows 上,安装MongoDB 社区版后,你可以使用以下命令创建必要的数据目录:

$ cd C:\
$ md "\data\db"

MongoDB 服务器通常会在 Linux 系统上默认启动;否则,在 Linux 和 OS X 上,可以使用以下命令启动服务器实例:

$ mongod

在 Windows 社区版上,从命令提示符运行以下命令将启动一个服务器实例:

C:\mongodb\bin\mongod.exe

使用 Docker 轻松安装 MongoDB

MongoDB 的安装可能有些棘手。例如,当前的 Ubuntu 变种(> 版本 22.04)存在不兼容的 SSL 库。如果你已经安装了Docker,一个工作的开发数据库在默认端口 27017 上只需一个命令即可:

$ sudo docker run -dp 27017:27017 -v local-mongo:/data/db
              --name local-mongo --restart=always mongo

这很好地避开了本地库不兼容性等问题。

集成开发环境

正如我在《IDE、框架和工具的神话》中所解释的,你并不需要一个 IDE 来编写 Python 或 JavaScript。现代浏览器(尤其是 Chrome)提供的开发工具意味着你实际上只需要一个好的代码编辑器就能拥有几乎最佳的设置。

这里的一个注意是,如今,中级到高级的 JavaScript 开发通常涉及到像 React、Vue 和 Svelte 这样的框架,这些框架受益于一个体面的 IDE 提供的各种功能,特别是处理多格式文件(其中 HTML、CSS 和 JS 都混合在一起)。好消息是,免费的Visual Studio Code(VSCode)已成为现代 Web 开发的事实标准。它有几乎所有插件,并且拥有庞大且活跃的社区,因此问题通常能够迅速得到解答和缺陷被解决。

对于 Python,我尝试过几个专用的 IDE,但它们从未让我满意。我试图解决的主要问题是找到一个像样的调试系统。在 Python 中使用文本编辑器设置断点并不是特别优雅,使用命令行调试器pdb有时感觉有点老派。尽管如此,Python 确实包含了一个相当不错的日志系统,可以稍微减轻其默认调试器的笨拙。VSCode 对于 Python 编程来说相当不错,但还有一些专门的 Python IDE 可能更为流畅。

以下是我尝试过的几个,并且不是很讨厌的:

PyCharm

这个选项提供了强大的代码辅助和良好的调试功能,可能会成为经验丰富的 Python 程序员最喜欢的 IDE 之一。

PyDev

如果你喜欢 Eclipse 并且可以容忍其相当大的占用空间,这可能非常适合你。

Wing Python IDE

这是一个可靠的选择,具有出色的调试器,并在十五年开发中逐步改进。

摘要

通过免费的打包 Python 发行版,如 Anaconda,以及在免费可用的 Web 浏览器中包含的复杂 JavaScript 开发工具,您所需的 Python 和 JavaScript 元素就只是几个点击之遥。再加上一个喜欢的编辑器和一个选择的数据库^(1),您基本上就可以开始了。还有一些附加库,比如node.js,可能会很有用,但不算必要。既然我们已经建立了我们的编程环境,接下来的章节将教授启动我们的数据转换之旅所需的基础知识,从 Python 和 JavaScript 之间的语言桥梁开始,沿着工具链逐步推进。

^(1) SQLite 非常适合开发目的,而且不需要在您的机器上运行服务器。

第二章:Python 和 JavaScript 之间的语言学习桥梁

这本书最雄心勃勃的方面可能是它涉及两种编程语言。此外,它只需要你精通其中一种语言。这仅仅是因为 Python 和 JavaScript(JS)是相当简单的语言,并且有很多共同之处。本章的目的是挖掘这些共同之处,并利用它们来构建两种语言之间的学习桥梁,以便在一种语言中获得的核心技能可以轻松地应用到另一种语言中。

在展示了这两种语言之间的关键相似之处和差异之后,我将展示如何为 Python 和 JS 设置学习环境。然后,本章的大部分内容将涉及核心的句法和概念差异,然后是我在进行数据可视化工作时经常使用的一些模式和习惯用法的选择。

相似之处和不同之处

除了语法差异之外,Python 和 JavaScript 实际上有很多共同之处。在短时间内,它们之间的切换几乎可以无缝进行。让我们从数据可视化者的角度来比较这两种语言:

这些是主要的相似之处:

  • 他们都可以在不需要编译步骤的情况下工作(即,它们是解释型的)。

  • 你可以使用交互式解释器来使用它们,这意味着你可以输入代码并立即看到结果。

  • 两者都有垃圾收集,这意味着它们会自动管理程序内存。

  • 与 C++、Java 等语言相比,两种语言都没有头文件、包装样板等。

  • 两者都可以在文本编辑器或轻量级 IDE 中愉快地开发。

  • 在两者中,函数都是一等公民,可以作为参数传递。

这些是关键的区别:

  • 可能最大的区别是 JavaScript 是单线程和非阻塞的,使用异步 I/O。这意味着简单的事情,比如文件访问,涉及到使用一个回调函数,传递给另一个函数,并在某些代码完成后调用,通常是异步的。

  • JS 基本上是用于 Web 开发,并且直到相对最近才与浏览器捆绑在一起,但 Python 几乎随处可见。

  • JS 是 Web 浏览器中唯一的一流语言,而 Python 被排除在外。

  • Python 有一个全面的标准库,而 JS 只有一套有限的实用对象(例如,JSON、Math)。

  • Python 有着相当经典的面向对象类,而 JS 使用原型对象。

  • JS 缺乏重量级的通用数据处理库。

这些差异突显了这本书需要是双语的必要性。JavaScript 在浏览器数据可视化方面的垄断需要一个传统的数据处理堆栈的补充。而 Python 则是最好的选择。

与代码交互

Python 和 JavaScript 的一大优势是它们可以即时解释,因此可以与其交互。Python 的解释器可以从命令行运行,而 JavaScript 的通常通过浏览器的控制台访问,通常可从内置的开发工具中获取。在本节中,我们将看到如何启动解释器会话并开始尝试您的代码。

Python

到目前为止,最好的命令行 Python 解释器是 IPython,它有三种形式:基本的终端版本、增强的图形版本和基于浏览器的笔记本。自 IPython 版本 4.0 以来,后两种已经分拆成 Jupyter 项目。Jupyter 笔记本是一个非常出色且相当新颖的创新,提供基于浏览器的交互式计算环境。笔记本的重要优势在于会话持久性和 web 访问的可能性。在其中分享编程会话及嵌入的数据可视化非常简便,使笔记本成为绝佳的教学工具以及恢复编程上下文的好方式。这也是为什么本书的 Python 章节配有 Jupyter 笔记本的原因。

要启动 Jupyter 笔记本,请在命令行运行 jupyter

$ jupyter notebook
[I 15:27:44.553 NotebookApp] Serving notebooks from local
directory:
...
[I 15:27:44.553 NotebookApp] http://localhost:8888/?token=5e09...

打开指定的 URL(在这种情况下是 http://localhost:8888),然后开始阅读或编写 Python 笔记本。

JavaScript

有很多选项可以尝试 JavaScript 代码而不启动服务器,尽管后者并不难。因为 JavaScript 解释器嵌入在所有现代 web 浏览器中,所以有许多网站可以让您尝试 JavaScript 片段以及 HTML 和 CSS,并查看结果。CodePen 是一个不错的选择。这些网站非常适合分享代码和尝试片段,通常还允许您通过几次鼠标点击添加库,如 D3.js

如果您想尝试代码一行或者查看活动代码的状态,基于浏览器的控制台是最佳选择。在 Chrome 中,您可以使用 Ctrl-Shift-J(Mac 上为 Command + Option + J)访问控制台。除了尝试一些小的 JavaScript 片段外,控制台还允许您深入到任何范围内的对象,显示它们的方法和属性。这是查看活动对象状态和搜索错误的好方法。

使用在线 JavaScript 编辑器的一个缺点是失去了您喜欢的编辑环境的优势,如 linting、熟悉的键盘快捷键等(参见 第四章)。在线编辑器往往基本功能简陋。如果您预计会进行广泛的 JavaScript 会话并想使用您喜欢的编辑器,最好的选择是运行本地服务器。

首先,创建一个项目目录——例如称为 sandpit——并添加一个包含 JS 脚本的最小 HTML 文件:

sandpit
├── index.xhtml
└── script.js

index.xhtml 文件只需几行代码,可以选择在其中使用 div 占位符来开始构建你的可视化或尝试一些 DOM 操作:

<!-- index.xhtml -->
<!DOCTYPE html>
<meta charset="utf-8">

<div id='viz'></div>

<script type="text/javascript" src="script.js" async></script>

然后可以向你的 script.js 文件中添加一些 JavaScript 代码:

// script.js
let data = [3, 7, 2, 9, 1, 11];
let sum = 0;
data.forEach(function(d){
    sum += d;
});

console.log('Sum = ' + sum);
// outputs 'Sum = 33'

使用 Python 的 http 模块在项目目录中启动开发服务器:

$ python -m http.server 8000
Serving HTTP on 0.0.0.0 port 8000 ...

然后在浏览器中打开 http://localhost:8000,按 Ctrl-Shift-J(OS X 上的 Cmd-Opt-J)访问控制台,你应该可以看到 图 2-1,显示脚本的日志输出(详见 第 4 章)。

dpj2 0201

图 2-1. 输出到 Chrome 控制台

现在我们已经学会了如何运行演示代码,让我们开始在 Python 和 JavaScript 之间建立桥梁。首先,我们将介绍语法上的基本差异。正如你将看到的那样,它们相差甚微,容易吸收。

基础桥梁工作

在本节中,我将对比这两种语言中编程的基本要素。

风格指南、PEP 8 和使用严格模式

JavaScript 的风格指南有些松散(人们通常默认使用像 React 这样的大型库所使用的风格),而 Python 则有一项专门的 Python Enhancement Proposal(PEP)。我建议熟悉 PEP-8,但不完全服从其领导地位。它在大多数事情上都是正确的,但在这里也有一些个人选择的余地。有一个方便的在线检查工具叫做 PEP8 Online,可以找出任何违反 PEP-8 的情况。许多 Python 爱好者正在转向 Black Python 代码格式化工具,它按照 PEP-8 规范接管了格式化任务。

在 Python 中,应使用四个空格缩进代码块。JavaScript 则不那么严格,但两个空格是最常见的缩进方式。

JavaScript 的最新增强(ECMAScript 5)之一是 'use strict' 指令,它强制启用严格模式。这种模式强制执行一些良好的 JavaScript 实践,包括捕获意外的全局声明,我强烈推荐使用它。要使用它,只需将字符串放在函数或模块的顶部:

(function(foo){
  'use strict';
  // ...
}(window.foo = window.foo || {}));

骆驼命名法对比下划线

JavaScript 通常使用骆驼命名法(例如 processStudentData)来命名变量,而 Python 根据 PEP-8 使用下划线(例如 process_student_data)来命名变量(参见示例 2-3 和 2-4)。按照惯例(Python 生态系统中的惯例比 JavaScript 更重要),Python 使用大写的骆驼命名法来声明类(见下面的示例),使用大写字母来定义常量,使用下划线来区分其他内容:

FOO_CONST = 10
class FooBar(object): # ...
def foo_bar():
    baz_bar = 'some string'

导入模块,包括脚本

在你的代码中使用其他库,无论是你自己的还是第三方的,对现代编程至关重要,这使得直到相对较近的时间内 JavaScript 没有专门的方法来做这件事显得更加令人惊讶。Python 有一个简单的导入系统,总体上运行得相当好。

JavaScript 前端的好消息是,自 ECMAScript 6 以来,JavaScript 已经解决了这个问题,通过增加了importexport语句来封装模块。我们现在有 JavaScript 模块(通常使用.mjs后缀),可以导入和导出封装的函数和对象,这是一个巨大的进步。在第 V 部分,我们将看到这些如何易于使用。

当你熟悉 JS 时,你可能想使用script标签导入第三方库,这通常会将它们作为对象添加到全局命名空间中。例如,为了使用 D3,你可以在你的 HTML 入口文件(通常是index.xhtml)中添加以下script标签:

<!DOCTYPE html>
<meta charset="utf-8">
  <script src="http://d3js.org/d3.v7.min.js"></script>

现在你可以像这样使用 D3 库:

let r = d3.range(0, 10, 2)
console.log(r)
// out: [0, 2, 4, 6, 8]

Python 自带“电池”,一个涵盖从扩展数据容器(collections)到处理 CSV 文件家族的全面库集合。如果你想使用其中之一,只需使用import关键字导入它:

In [1]: import sys

In [2]: sys.platform
Out[2]: 'linux'

如果你不想导入整个库或想使用别名,你可以使用asfrom关键字代替:

import pandas as pd
from csv import DictWriter, DictReader
from numpy import * ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)

df = pd.read_json('data.json')
reader = DictReader('data.csv')
md = median([12, 56, 44, 33])

1

这会将模块中的所有变量导入到当前命名空间,几乎总是一个坏主意。其中一个变量可能掩盖一个现有的变量,并且违反了 Python 显示优于隐式的最佳实践。唯一的例外是,如果你在交互式 Python 解释器中使用。在这种有限的情况下,从库中导入所有函数可能有意义,以减少按键次数;例如,如果进行一些 Python 数学操作,导入所有数学函数(from math import *)。

如果导入非标准库,Python 会使用sys.path来尝试找到它。sys.path包括:

  • 导入模块的目录(当前目录)

  • PYTHONPATH变量,包含一个目录列表

  • 安装依赖的默认位置,使用pipeasy_install安装的库通常会放在这里

大型库通常被打包分成子模块。这些子模块通过点符号访问:

import matplotlib.pyplot as plt

通过文件系统构建包,通常是空的__init__.py文件,如示例 2-1 所示。有 init 文件使得目录对 Python 的导入系统可见。

示例 2-1. 构建一个 Python 包
mypackage
├── __init__.py
...
├── core
│   └── __init__.py
│   ...
...
└── io
    ├── __init__.py
    └── api.py
    ...
    └── tests
        └── __init__.py
        └── test_data.py
        └── test_excel.py ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
        ...
...

1

你可以使用from mypackage.io.tests import test_excel来导入这个模块。

您可以使用点符号从根目录(即 示例 2-1 中的 mypackage)访问 sys.path 上的包。 import 的一个特例是包内引用。test_excel.py 子模块在 示例 2-1 中可以绝对和相对地从 mypackage 包导入子模块:

from mypackage.io.tests import test_data ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
from . import test_data ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
import test_data ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
from ..io import api ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)

1

绝对导入 test_data.py 模块,从包的主目录。

2

显式(. import)和隐式相对导入。

3

来自 tests 兄弟包的相对导入。

JavaScript 模块

JavaScript 现在具有模块,可以导入和导出封装的变量。JS 挑选了它的导入语法,使用了许多对 Python 开发者来说很熟悉但在我看来改进了的东西。以下是简要介绍:

假设我们有一个 JS 入口模块 index.js,它想要使用位于 lib 目录中的库模块 libFoo.js 中的一些函数或对象。文件结构如下所示:

.
├── index.mjs
└── lib
    └── libFoo.mjs

libFoo.mjs 中,我们导出一个虚拟函数,并可以使用 export default 导出模块的单个对象,通常是一个带有实用方法的 API:

// lib/libFoo.mjs export let findOdds = (a) => {
  return a.filter(x => x%2)
}

let api = {findOdds} ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)

export default api

1

使用简写属性名创建对象的示例,相当于 {findOdds: findOdds}

要从我们的索引模块导入导出的函数和对象,我们使用 import 语句,它允许我们导入默认的 API 或通过花括号选择按名称导出的变量:

// index.mjs
import api from './lib/libFoo.mjs'
import { findOdds } from './lib/libFoo.mjs'

let odds = findOdds([2, 4, 24, 33, 5, 66, 24])
console.log('Odd numbers: ', odds)

odds = api.findOdds([12, 43, 22, 39, 52, 21])
console.log('Odd numbers: ', odds)

JS 导入还支持别名,这可以成为优秀的代码消毒器:

// index.mjs
import api as foo from './lib/libFoo.mjs'
import { findOdds as odds } from './lib/libFoo.mjs'
// ...

正如你所见,JavaScript 的导入和导出与 Python 非常相似,尽管在我的经验中稍微更加用户友好。你可以在 Mozilla 的 exportsimports 文档中查看更多细节。

保持您的命名空间干净

Python 模块中定义的变量是封装的,这意味着除非你显式地导入它们(例如,from foo import baa),否则你将使用点符号从导入的模块命名空间访问它们(例如,foo.baa)。全局命名空间的模块化被视为非常好的事情,符合 Python 的关键原则之一:显式声明的重要性胜过隐式声明。分析某人的 Python 代码时,你应该能够准确地看到类、函数或变量来自哪里。同样重要的是,保留命名空间限制了变量冲突或遮蔽的可能性——这在代码库变大时是一个很大的潜在问题。

过去 JavaScript 的主要批评之一(也是公正的),是其对命名空间约定的快速且不拘一格。其中最突出的例子是,在函数外声明的变量或者缺少var关键字^(6),是全局的,而不是限定在其声明所在的脚本内。使用现代化、模块化的 JavaScript,你可以像 Python 一样进行封装,通过导入和导出变量来实现。

JS 模块是一个相对较新的游戏变革者——过去常用的一种模式是创建自调用函数,以便将局部变量与全局命名空间隔离开来。这样一来,通过var声明的所有变量都将局部化到脚本/函数中,防止它们污染全局命名空间。JavaScript 中的let关键字具有块级作用域,几乎总是优于var。你希望在其他脚本中使用的任何对象、函数和变量,都可以附加到全局命名空间的一个对象上。

示例 2-2 展示了一个模块模式。模板的头部和尾部(标记为 13)有效地创建了一个封装模块。这种模式远非是 JavaScript 模块化的完美解决方案,但在 ECMAScript 6 采用了主流浏览器的专用导入系统之前,它是我所知道的最佳折中方案。

示例 2-2. JavaScript 的模块模式
(function(nbviz) { ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
    'use strict';
    // ...
    nbviz.updateTimeChart = function(data) {..} ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
    // ... }(window.nbviz = window.nbviz || {})); ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)

1

接收全局的nbviz对象。

2

updateTimeChart方法附加到全局的nbviz对象上,有效地导出它。

3

如果全局(窗口)命名空间中存在nbviz对象,则将其传递给模块函数;否则,将其添加到全局命名空间中。

输出“Hello World!”

到目前为止,任何编程语言最受欢迎的初始演示,是在某种形式上打印或传递“Hello World!”,因此让我们从 Python 和 JavaScript 的输出开始。

Python 的输出简直不能再简单:

print('Hello World!')

JavaScript 没有打印函数,但你可以将输出记录到浏览器控制台:

console.log('Hello World!')

简单的数据处理

要了解语言差异的一个好方法是查看两种语言中相同功能的编写方式。示例 2-3 和 2-4 展示了 Python 和 JavaScript 中数据处理的小型构造示例,我们将使用它们来比较 Python 和 JS 的语法,并标记为大写字母(A、B...),以便进行代码块比较。

示例 2-3. Python 的简单数据转换
# A
student_data = [
    {'name': 'Bob', 'id':0, 'scores':[68, 75, 56, 81]},
    {'name': 'Alice', 'id':1,  'scores':[75, 90, 64, 88]},
    {'name': 'Carol', 'id':2, 'scores':[59, 74, 71, 68]},
    {'name': 'Dan', 'id':3, 'scores':[64, 58, 53, 62]},
]

# B
def process_student_data(data, pass_threshold=60,
                         merit_threshold=75):
    """ Perform some basic stats on some student data. """

    # C
    for sdata in data:
        av = sum(sdata['scores'])/float(len(sdata['scores']))

        if av > merit_threshold:
            sdata['assessment'] = 'passed with merit'
        elif av >= pass_threshold:
            sdata['assessment'] = 'passed'
        else:
            sdata['assessment'] = 'failed'
        # D
        print(f"{sdata['name']}'s (id: {sdata['id']}) final assessment is:\
        {sdata['assessment'].upper()}")
        # For Python versions before 3.7, the old-style string formatting is equivalent
        # print("%s's (id: %d) final assessment is: %s"%(sdata['name'],\
        # sdata['id'], sdata['assessment'].upper()))     sdata['name'], sdata['id'],\
        # sdata['assessment'].upper()))
        print(f"{sdata['name']}'s (id: {sdata['id']}) final assessment is:\
         {sdata['assessment'].upper()}")

        sdata['average'] = av

# E
if __name__ == '__main__':
    process_student_data(student_data)
示例 2-4. JavaScript 的简单数据处理
let studentData =  ![1
    {name: 'Bob', id:0, 'scores':[68, 75, 76, 81]},
    {name: 'Alice', id:1, 'scores':[75, 90, 64, 88]},
    {'name': 'Carol', id:2, 'scores':[59, 74, 71, 68]},
    {'name': 'Dan', id:3, 'scores':[64, 58, 53, 62]},
];

// B function processStudentData(data, passThreshold, meritThreshold){
    passThreshold = typeof passThreshold !== 'undefined'?\
    passThreshold: 60;
    meritThreshold = typeof meritThreshold !== 'undefined'?\
    meritThreshold: 75;

    // C
    data.forEach(function(sdata){
        let av = sdata.scores.reduce(function(prev, current){
            return prev+current;
        },0) / sdata.scores.length;

        if(av > meritThreshold){
            sdata.assessment = 'passed with merit';
        }
        else if(av >= passThreshold){
            sdata.assessment = 'passed';
        }
        else{
            sdata.assessment = 'failed';
        }
        // D
        console.log(sdata.name + "'s (id: " + sdata.id +
          ") final assessment is: " +
            sdata.assessment.toUpperCase());
        sdata.average = av;
    });

}

// E processStudentData(studentData);

1

请注意对象键中有些带引号有些不带引号的故意和有效的不一致性。

字符串构造

示例 2-3 和 2-4 中的 D 部分显示了将输出打印到控制台或终端的标准方法。JavaScript 没有 print 语句,但可以通过 console 对象记录到浏览器的控制台:

console.log(sdata.name + "'s (id: " + sdata.id +
  ") final assessment is: " + sdata.assessment.toUpperCase());

请注意,整数变量 id 被强制转换为字符串,允许串联。Python 不执行这种隐式强制转换,因此尝试以这种方式将字符串添加到整数会导致错误。而是通过 strrepr 函数显式转换为字符串形式。

在 示例 2-3 的 A 部分中,输出字符串是用 C 类型格式化构造的。通过最终的元组 (%(…​)) 提供字符串 (%s) 和整数 (%d) 占位符:

print("%s's (id: %d) final assessment is: %s"
  %(sdata['name'], sdata['id'], sdata['assessment'].upper()))

近年来,我很少使用 Python 的 print 语句,而是选择更强大和灵活的 logging 模块,在以下代码块中展示。使用它需要更多的努力,但是非常值得。日志记录使您可以灵活地将输出定向到文件和/或屏幕,调整日志级别以优先处理某些信息,以及其他许多有用的功能。在 Python 文档 中查看详细信息。

import logging
logger = logging.getLogger(__name__) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
//...
logger.debug('Some useful debugging output')
logger.info('Some general information')

// IN INITIAL MODULE
logging.basicConfig(level=logging.DEBUG) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)

1

创建一个以该模块名称命名的记录器。

2

将日志级别设置为“debug”提供了可用的最详细信息(参见 Python 文档 了解更多详情)。

显著的空白与大括号

Python 最显著的语法特点是显著的空白。与像 C 和 JavaScript 这样的语言使用空白来增强可读性并可以轻易地压缩成一行不同,(7) 在 Python 中,前导空格用于指示代码块,移除它们会改变代码的含义。维护正确的代码对齐所需的额外工作远远超过了增加的可读性 —— 你阅读代码的时间远远超过写代码的时间,而 Python 的易读性可能是 Python 库生态系统如此健康的主要原因。四个空格几乎是强制性的(参见 PEP-8),而我个人偏好所谓的软制表符,即你的编辑器插入(和删除)多个空格而不是制表符字符。(8)

在以下代码中,return 语句的缩进按照惯例必须是四个空格:^(9)

def doubler(x):
    return x * 2
#  |<-this spacing is important

JavaScript 不关心语句和变量之间的空格数量,使用大括号来标记代码块;此代码中的两个 doubler 函数是等效的:

let doubler = function(x){
  return x * 2;
}

let doubler=function(x){return x*2;}

Python 的缩进被过分强调,但我认识的大多数优秀编码者都设置了编辑器以强制执行缩进代码块和一致的外观。Python 只是强制执行这种良好的实践。再次强调,我认为 Python 代码的极高可读性对 Python 极其健康的生态系统贡献了同样多,这与其简单的语法一样重要。

注释和文档字符串

要向代码添加注释,Python 使用井号(#):

# ex.py, a single informative comment

data = {} # Our main data-ball

相比之下,JavaScript 使用 C 语言约定的双反斜杠(//)或 /* ... */ 来进行多行注释:

// script.js, a single informative comment
/* A multiline comment block for
function descriptions, library script
headers, and the like */
let data = {}; // Our main data-ball

除了注释外,Python 还有文档字符串(doc-strings)的约定,符合其可读性和透明性的哲学。在 示例 2-3 中的 process_student_data 函数顶部有一行三引号的文本,将自动分配给函数的 __doc__ 属性。你也可以使用多行文档字符串:

def doubler(x):
    """This function returns double its input."""
    return 2 * x

def sanitize_string(s):
    """This function replaces any string spaces
 with '-' after stripping any whitespace
 """
    return s.strip().replace(' ', '-')

文档字符串是一个很好养成的习惯,特别是在协作工作中。它们被大多数优秀的 Python 编辑工具集所理解,并且也被自动化文档库(如 Sphinx)使用。字符串字面量的文档字符串可以作为函数或类的 doc 属性访问。

使用letvar声明变量

JavaScript 使用 letvar 来声明变量。一般来说,let 几乎总是正确的选择。

严格来说,JS 语句应该以分号结尾,而不是 Python 的换行符。你会看到有些例子省略了分号,在现代浏览器中通常不会有问题。有一些边缘情况可能需要使用分号(例如,可能会影响去除空格的代码缩小器和压缩器),但通常来说,减少混乱和提高可读性是一种值得的编码方式,不使用分号。

提示

JavaScript 具有变量提升机制,这意味着使用 var 声明的变量会在任何其他代码之前进行处理。这意味着在函数中的任何位置声明它们都等效于在顶部声明它们。这可能导致奇怪的错误和混乱。明确将 var 放在顶部可以避免这种情况,但更好的做法是使用现代的 let 并具有作用域声明。

字符串和数字

在 JavaScript 中,学生数据中使用的 name 字符串(参见示例 2-3 和 2-4 的第 A 部分)将被解释为 UCS-2(unicode UTF-16 的父类)(10),而在 Python 3 中将被解释为 Unicode(默认为 UTF-8)(11)。

两种语言都允许在字符串中使用单引号和双引号。如果要在字符串中包含单引号或双引号,可以使用另一种引号进行封闭,如下所示:

pub_name = "The Brewer's Tap"

示例 2-4 中 A 节中的scores以 JavaScript 的一种数值类型,双精度 64 位(IEEE 754)浮点数存储。尽管 JavaScript 有一个parseInt转换函数,当与浮点数一起使用时,^(12) 它实际上只是一个舍入操作符,类似于floor。解析的number的类型仍然是number

let x = parseInt(3.45); // 'cast' x to 3
typeof(x); // "number"

Python 有两种数值类型:32 位的int,学生分数将被转换为这种类型,以及与 JS 的number相当的float(IEE 754)。这意味着 Python 可以表示任何整数,而 JavaScript 更为有限。^(13) Python 的强制类型转换会改变类型:

foo = 3.4 # type(foo) -> float
bar = int(3.4) # type(bar) -> int

Python 和 JavaScript 的数字的好处在于它们易于使用并且通常能够做到你想要的。如果需要更高效的东西,Python 有 NumPy 库,允许精细控制你的数值类型(你将在第七章学到更多关于 NumPy 的知识)。在 JavaScript 中,除了一些前沿项目,你基本上只能使用 64 位浮点数。

布尔值

Python 在使用命名布尔运算符上与 JavaScript 和 C 类语言不同。除此之外,它们的工作方式几乎符合预期。这张表格给出了一个比较:

Python bool True False not and or
JavaScript boolean true false ! && ||

Python 的大写TrueFalse对任何 JavaScript 开发者来说都是一个明显的障碍,反之亦然,但是任何良好的语法高亮工具和你的代码检查工具都应该能够捕捉到这一点。

与总是返回布尔值 true 或 false 不同,Python 和 JavaScript 的and/or表达式返回其中一个参数的结果,当然,这个参数可以是布尔值。表 2-1 展示了这是如何工作的,使用 Python 来演示。

表 2-1. Python 的布尔运算符

操作 结果
x or y 如果 x 为假,则为 y,否则为 x
x and y 如果 x 为假,则为 x,否则为 y
not x 如果 x 为假,则为True,否则为False

这个事实允许偶尔有用的变量赋值:

rocket_launch = True
(rocket_launch == True and 'All OK') or 'We have a problem!'
Out:
'All OK'

rocket_launch = False
(rocket_launch == True and 'All OK') or 'We have a problem!'
Out:
'We have a problem!'

数据容器:dicts, objects, lists, Arrays

粗略地说,JavaScript 中的objects可以像 Python 中的dicts 一样使用,而 Python 中的lists 则像 JavaScript 中的数组。Python 还有一个元组容器,类似于不可变的列表。以下是一些例子:

# Python
d = {'name': 'Groucho', 'occupation': 'Ruler of Freedonia'}
l = ['Harpo', 'Groucho', 99]
t = ('an', 'immutable', 'container')
// JavaScript
d = {'name': 'Groucho', 'occupation': 'Ruler of Freedonia'}
l = ['Harpo', 'Groucho', 99]

如示例 2-3 和 2-4 中 A 节所示,Python 的dict键必须是用引号括起来的字符串(或可散列类型),而 JavaScript 允许你在属性是有效标识符(即不包含特殊字符如空格和破折号)时省略引号。所以在我们的studentData对象中,JS 隐式地将属性'name'转换为字符串形式。

学生数据声明看起来非常相似,实际上也基本相同。需要注意的主要区别是,虽然 JS 中 studentData 中用花括号括起来的容器看起来像 Python 的 dict,但它们实际上是 JS 对象的一种简写声明,这是一种略有不同的数据容器。

在 JS 数据可视化中,我们倾向于使用对象数组作为主要数据容器,在这里,JS 对象的功能与 Python 程序员所期望的功能非常相似。事实上,正如下面的代码所示,我们既获得了点表示法又获得了键字符串访问的优势,前者在适用时更受青睐(需要用引号括起来的键名,如含有空格或破折号的情况):

let foo = {bar:3, baz:5};
foo.bar; // 3
foo['baz']; // 5, same as Python

值得注意的是,虽然 JavaScript 的对象可以像 Python 字典那样使用,但它们实际上远不止于简单的容器(除了像字符串和数字这样的基本类型外,JavaScript 的几乎所有东西都是对象)。^(14) 但在大多数数据可视化示例中,它们与 Python 的 dict 非常类似。

表 2-2 转换基本列表操作。

表 2-2. 列表和数组

JavaScript 数组(a) Python 列表(l)
a.length len(l)
a.push(item) l.append(item)
a.pop() l.pop()
a.shift() l.pop(0)
a.unshift(item) l.insert(0, item)
a.slice(start, end) l[start:end]
a.splice(start, howMany, i1, …​) l[start:end] = [i1, …​]

函数

例子 2-3 和 2-4 的 B 部分展示了函数声明。Python 使用 def 表示函数:

def process_student_data(data, pass_threshold=60,
                         merit_threshold=75):
    """ Perform some basic stats on some student data. """
    ...

而 JavaScript 使用 function

function processStudentData(data, passThreshold=60, meritThreshold=75){
    ...
}

两者都有参数列表。在 JS 中,函数的代码块用花括号 { …​ } 表示;在 Python 中,代码块由冒号和缩进定义。

JS 还有一种称为 函数表达式 的定义函数的替代方式,你可以在这个例子中看到:

let processStudentData = function( ...){...}

现在有一种更受欢迎的简化形式:

let processStudentData = ( ...) => {...}

这些差异现在可以不用担心。

函数参数是一个领域,Python 处理比 JavaScript 更为复杂。正如你在 process_student_data(例子 2-3 中的 B 部分)中看到的那样,Python 允许参数有默认值。在 JavaScript 中,所有未在函数调用中使用的参数都声明为 undefined

遍历:for 循环和函数式替代方法

例子 2-3 和 2-4 的 C 部分展示了 Python 和 JavaScript 之间第一个关键差异 —— 它们对 for 循环的处理方式。

Python 的 for 循环对任何迭代器(如数组和 dict)都是简单直观且高效的。dict 的一个需要注意的地方是,标准迭代是按键而非项进行的。例如:

foo = {'a':3, 'b':2}
for x in foo:
    print(x)
# outputs 'a' 'b'

若要遍历键值对,使用 dictitems 方法,如下所示:

for x in foo.items():
    print(x)
# outputs key-value tuples ('a', 3) ('b' 2)

你可以在 for 语句中方便地分配键值对。例如:

for key, value in foo.items():

因为 Python 的for循环适用于具有正确迭代器管道的任何内容,所以您可以做一些很酷的事情,比如循环遍历文件行:

for line in open('data.txt'):
    print(line)

对于从 Python 过来的人来说,JS 的for循环是一件相当可怕和不直观的事情。以下是一个例子:

for(let i in ['a', 'b', 'c']){
  console.log(i)
}
// outputs 1, 2, 3

JS 的for .. in返回数组项的索引,而不是数组项本身。更糟糕的是,对于 Pythonista 来说,迭代的顺序不能保证,因此索引可能以非连续的顺序返回。

在 Python 和 JS 的for循环之间进行切换几乎没有无缝衔接,要求您保持警觉。好消息是,这些日子几乎不需要使用 JS 的for循环。事实上,我几乎从不需要。这是因为 JS 最近获得了一些非常强大的一流函数能力,这些能力具有更高的表达能力,且与 Python 的混淆机会更少,一旦您习惯了它们,很快就变得不可或缺。^(16)

示例 2-4 中的第 C 部分展示了现代 JavaScript 数组可用的功能方法之一,即forEach()forEach()迭代数组项,依次将它们发送到第一个参数中定义的匿名回调函数中,其中可以处理它们。这些功能方法的真正表现力来自它们的链式调用(映射、过滤等),但是现在我们已经有了更清洁、更优雅的迭代,没有旧方法的笨重记录。

回调函数可以选择性地接收索引和原始数组作为第二个参数:

data.forEach(function(currentValue, index){...})

直到最近,即使是迭代object的键值对也是相当棘手的。不像 Python 的dictobject可能会从原型链继承属性,所以您必须使用hasOwnProperty保护来过滤这些属性。您可能会遇到这样的代码:

let obj = {a:3, b:2, c:4};
for (let prop in obj) {
  if( obj.hasOwnProperty( prop ) ) {
    console.log("o." + prop + " = " + obj[prop]);
  }
}
// out: o.a = 3, o.b = 2, o.c = 4

虽然 JS 数组有一组本地功能迭代器方法(mapreducefiltereverysumreduceRight),object作为伪字典的外观则没有。好消息是,object类最近新增了一些有用的附加方法来填补这一空白。因此,您可以使用entries方法迭代键值对:

let obj = {a:3, b:2, c:4};
for (const [key, value] of Object.entries(object1)) {
  console.log(`${key}: ${value}`); ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
}
// out: a: 3 //      b: 2 ...

1

请注意${foo}这种形式的字符串模板,用于打印变量。

条件语句:if, else, elif, switch

示例中的第 C 部分展示了 Python 和 JavaScript 条件语句的应用,分别为 2-3 和 2-4。除了 JavaScript 使用括号外,语句非常相似;唯一的真正区别是 Python 额外的elif关键字,一个方便的else if联合体。

尽管有很多请求,Python 没有像大多数高级语言中那样的switch语句。JS 有,允许您做到这一点:

switch(expression){
  case value1:
    // execute if expression === value1
    break; // optional end expression
  case value2:
    //...
  default:
    // if other matches fail
}

对于 Python 爱好者来说,好消息是在 3.10 版本中,Python 获得了一个非常强大的模式匹配条件,可以作为 switch 语句使用,但可以做的事情远不止于此。因此,我们可以这样在不同情况之间进行切换:

for value in [value1, value2, value3]:
    match value:
        case value1:
            # do foo
        case value2:
            # do baa
        case value3:
            # do baz

文件输入和输出

基于浏览器的 JavaScript 没有真正等效的文件输入和输出(I/O),但 Python 的实现非常简单:

# READING A FILE
f = open("data.txt") # open file for reading

for line in f: # iterate over file lines
    print(line)

lines = f.readlines() # grab all lines in file into array
data = f.read() # read all of file as single string

# WRITING TO A FILE
f = open("data.txt", 'w')
# use 'w' to write, 'a' to append to file
f.write("this will be written as a line to the file")
f.close() # explicitly close the file

一个被强烈推荐的最佳实践是在打开文件时使用 Python 的 with, as 上下文管理器。这确保在离开代码块时自动关闭文件,本质上为 try, except, finally 块提供了语法糖。这里是使用 with, as 打开文件的方式:

with open("data.txt") as f:
    lines = f.readlines()
    ...

然而,JavaScript 也有大致相似的 fetch 方法,用于从网络上获取资源,基于其 URL。因此,要从网站服务器获取数据集,在 static/data 目录下执行如下操作:

fetch('/static/data/nobel_winners.json')
  .then(function(response) {
  console.log(response.json())
})
Out:
[{name: 'Albert Einstein', category: 'Physics'...},]

Fetch API 在 Mozilla 上有详细的文档。

类和原型

可能比任何其他主题更容易引起混淆的是 JavaScript 选择原型而不是经典类作为其主要的面向对象编程(OOP)元素。对于 Python 程序员来说,这确实需要一些适应,因为在 Python 中类是无处不在的,但实际上,根据我的经验,这种学习曲线是短暂且相对浅显的。

我记得,当我第一次开始涉足像 C++ 这样更高级的语言时,迷上了面向对象编程的承诺,特别是基于类的继承。多态性当时非常流行,形状类被子类化为矩形和椭圆,这些又进一步被特化为更专门的正方形和圆形。

很快就意识到,在教科书中清晰的类划分在实际编程中很少能找到,并且尝试平衡通用和特定的 API 很快变得复杂。从这个意义上说,我发现组合和混合比尝试扩展子类化更有用作为编程概念,通常通过使用 JavaScript 中的函数式编程技术来避免所有这些问题。尽管如此,类/原型区别是两种语言之间明显的差异,你理解其细微差别的越多,编码能力就会越好。^(18)

Python 的类非常简单,并且和大多数语言一样容易使用。我现在倾向于将它们视为一种方便的方式来封装数据并提供方便的 API,很少扩展子类化超过一代。这里有一个简单的示例:

class Citizen(object):

    def __init__(self, name, country): ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
        self.name = name
        self.country = country

     def __str__(self): ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
        return f'Citizen {self.name} from {self.country}'

     def print_details(self):
         print(f'Citizen {self.name} from {self.country}')

groucho = Citizen('Groucho M.', 'Freedonia') ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
print(groucho) # or groucho.print_details()
# Out:
# Citizen Groucho M. from Freedonia

1

Python 类有许多双下划线的特殊方法,__init__是最常见的,当创建类实例时调用。所有实例方法都有一个显式的第一个self参数(你可以给它取其他名字,但这是一个非常糟糕的主意),它指的是实例本身。在这种情况下,我们用它来设置名称和国家属性。

2

你可以覆盖类的字符串方法,在调用实例的print函数时使用。

3

创建一个带有名称和国家的新Citizen实例。

Python 遵循了一种相当经典的类继承模式。这很容易做到,这可能是 Python 程序员大量使用它的原因。让我们自定义Citizen类来创建一个(诺贝尔奖)Winner类,并添加几个额外的属性:

class Winner(Citizen):

    def __init__(self, name, country, category, year):
        super().__init__(name, country) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
        self.category = category
        self.year = year

    def __str__(self):
        return 'Nobel winner %s from %s, category %s, year %s'\
        %(self.name, self.country, self.category,\
        str(self.year))

w = Winner('Albert E.', 'Switzerland', 'Physics', 1921)
w.print_details()
# Out:
# Nobel prizewinner Albert E. from Switzerland, category Physics,
# year 1921

1

我们想要重用超类Citizen__init__方法,使用这个Winner实例作为selfsuper方法将继承树从其第一个参数扩展一个分支,并将第二个参数作为类实例方法的实例。

我认为我读过的关于 JavaScript 原型和经典类之间主要区别的最佳文章是 Reginald Braithwaite 的“OOP, JavaScript, and so-called Classes”。这段引文很好地总结了类和原型之间的区别:

原型(prototype)和类之间的区别类似于模型房屋和家庭蓝图之间的区别。

当你实例化一个 C++或 Python 类时,会遵循一个蓝图,创建一个对象并调用其继承树中的各种构造函数。换句话说,你是从头开始构建一个漂亮、崭新的类实例。

使用 JavaScript 原型,你从一个有房间(方法)的模型房屋(对象)开始。如果你想要一个新的客厅,你可以用更好颜色的东西替换旧的。如果你想要一个新的温室,那么只需做一个扩展。但与蓝图从头开始构建不同,你是在适应和扩展现有对象。

在搞定了必要的理论并提醒对象继承虽然有用但在数据可视化中并不普遍的情况下,让我们来看一个简单的 JavaScript 原型对象示例 Example 2-5。

示例 2-5. 一个简单的 JavaScript 对象
let Citizen = function(name, country){ ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
  this.name = name; ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
  this.country = country;
};

Citizen.prototype = { ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
  logDetails: function(){
    console.log(`Citizen ${this.name} from ${this.country}`);
  }
};

let c = new Citizen('Groucho M.', 'Freedonia'); ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/4.png)

c.logDetails();
Out:
Citizen Groucho M. from Freedonia

typeof(c) # object

1

这个函数本质上是一个初始化器,由new运算符调用。

2

this 是函数调用上下文的隐式引用。就目前而言,它的行为与预期相符,尽管它看起来有点像 Python 的 self,但它们是完全不同的,我们稍后会看到。

3

此处指定的方法将覆盖继承链上所有原型方法,并被从 Citizen 派生的任何对象继承。

4

new 用于创建一个新对象,将其原型设置为 Citizen 构造函数的原型,然后在新对象上调用 Citizen 构造函数。

JavaScript 最近获得了一些语法糖,允许声明类。这本质上包装了面向对象形式(参见 示例 2-5)以更加熟悉的形式呈现,如来自类似 Java 和 C# 的编程语言的程序员。我认为可以说,在前端、基于浏览器的 JavaScript 中,类并没有真正流行起来,有点被强调可重用组件的新框架(如 React、Vue、Svelte)所取代。这是我们如何实现 示例 2-5 中展示的 Citizen 对象的方式:

class Citizen {
  constructor(name, country) {
    this.name = name
    this.country = country
  }

  logDetails() {
    console.log(`Citizen ${this.name} from ${this.country}`)
  }
}

const c = new Citizen('Groucho M.', 'Freedonia')

我包含了 示例 2-5,展示了 JavaScript 对象实例化中的 new 使用,因为你会经常遇到它的使用。但语法已经有点笨拙,当你尝试进行继承时会变得更糟。ECMAScript 5 引入了 Object.create 方法,这是创建对象和实现继承的更好方式。我建议在你自己的代码中使用它,但是 new 可能会在一些第三方库中出现。

让我们使用 Object.create 创建一个 Citizen 及其 Winner 子类。强调一下,JavaScript 有很多方法可以做到这一点,但 示例 2-6 展示了我找到的最干净的方法和我的个人模式。

示例 2-6. 使用 Object.create 进行原型继承
let Citizen = { ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
    setCitizen: function(name, country){
        this.name = name;
        this.country = country;
        return this;
    },
    printDetails: function(){
        console.log('Citizen ' + this.name + ' from ',\
        + this.country);
    }
};

let Winner = Object.create(Citizen);

Winner.setWinner = function(name, country, category, year){
    this.setCitizen(name, country);
    this.category = category;
    this.year = year;
    return this;
};

Winner.logDetails = function(){
    console.log('Nobel winner ' + this.name + ' from ' +
    this.country + ', category ' + this.category + ', year ' +
    this.year);
};

let albert = Object.create(Winner)
    .setWinner('Albert Einstein', 'Switzerland', 'Physics', 1921);

albert.logDetails();
// Out: // Nobel winner Albert Einstein from Switzerland, category // Physics, year 1921

1

Citizen 现在是一个对象,而不是构造函数。将其视为任何新建筑(如 Winner)的基础。

再次强调,原型继承在 JavaScript 数据可视化中并不常见,特别是在以声明式和函数式模式为重点的 800 磅大猩猩 D3 中,原始的未封装数据被用来在网页上留下印记。

这一部分关于基本语法差异的内容,将以棘手的类/原型比较作结。现在让我们看看 Python 和 JS 数据可视化工作中常见的一些模式。

实践中的差异

JS 和 Python 的语法差异很重要,必须了解,幸运的是它们的语法相似性超过了差异。命令式编程的核心部分,循环、条件语句、数据声明和操作大体相同。在数据处理和数据可视化的专业领域,语言的一级函数允许常见习惯用法更加突出。

接下来是从数据可视化师的视角看到的 Python 和 JavaScript 中一些重要模式和习惯用法的非全面列表。在可能的情况下,给出了两种语言之间的翻译。

方法链

JavaScript 的常见习语是 方法链,它由最常用的库 jQuery 推广,并且在 D3 中被广泛使用。方法链涉及从自己的方法返回一个对象,以便在结果上调用另一个方法,使用点符号表示:

let sel = d3.select('#viz')
    .attr('width', '600px') ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
    .attr('height', '400px')
    .style('background', 'lightgray');

1

attr 方法返回调用它的 D3 选择器,然后用于调用另一个 attr 方法。

Python 中很少见到方法链,因为它通常主张每行只有一个语句,以保持简单和可读性。

枚举列表

经常需要在遍历列表时跟踪项的索引。Python 有一个非常方便的内置函数 enumerate 正是为了这个原因:

names = ['Alice', 'Bob', 'Carol']

for i, n in enumerate(names):
    print(f'{i}: {n}')
Out:
0: Alice
1: Bob
2: Carol

JavaScript 的列表方法,如 forEach 和函数式的 mapreducefilter,向回调函数提供迭代的项及其索引:

let names = ['Alice', 'Bob', 'Carol'];

names.forEach(function(n, i){
    console.log(i + ': ' + n);
});
Out:
0: Alice
1: Bob
2: Carol

元组解包

Python 最初采用的一个很酷的技巧是使用元组解包来交换变量:

(a, b) = (b, a)

注意括号是可选的。这可以更实际地用作减少临时变量的一种方法,例如在 斐波那契函数 中:

def fibonacci(n):
    x, y = 0, 1
    for i in range(n):
        print(x)
        x, y = y, x + y
# fibonacci(6) -> 0, 1, 1, 2, 3, 5

如果你想忽略其中一个解包的变量,可以使用下划线:

winner = 'Albert Einstein', 'Physics', 1921, 'Swiss'

name, _, _, nationality = winner ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
print(f'{name}, {nationality}')
# Albert Einstein, Swiss

1

Python 3 有一个 * 操作符,在这种情况下,我们可以使用它来解包我们的变量:name, *_, nationality = winner

JavaScript 语言正在迅速适应,并最近获得了一些非常强大的 解构能力。通过添加 展开操作符 (...),这使得一些非常简洁的数据操作成为可能:

let a, b, rem ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)

[a, b] = [1, 2]
// swap variables [a, b] = [b, a]
// using the spread operator [a, b, ...rem] = [1, 2, 3, 4, 5, 6,] // rem = [3, 4, 5, 6]

1

不像 Python,你仍然需要声明你将要使用的任何变量。

集合

Python 中最有用的一个“电池”是 collections 模块。它提供了一些专门的容器数据类型来增强 Python 的标准集合。它有一个 deque,提供了一个类似列表的容器,在两端快速添加和弹出;一个 OrderedDict,记住添加条目的顺序;一个 defaultdict,提供一个设置字典默认值的工厂函数;以及一个 Counter 容器,用于计数可散列对象,等等。我经常使用最后三个。以下是一些示例:

from collections import Counter, defaultdict, OrderedDict

items = ['F', 'C', 'C', 'A', 'B', 'A', 'C', 'E', 'F']

cntr = Counter(items)
print(cntr)
cntr['C'] -=1
print(cntr)
Out:
Counter({'C': 3, 'A': 2, 'F': 2, 'B': 1, 'E': 1})
Counter({'A': 2, 'C': 2, 'F': 2, 'B': 1, 'E': 1})

d = defaultdict(int) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)

for item in items:
    d[item] += 1 ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)

d
Out:
defaultdict(<type 'int'>, {'A': 2, 'C': 3, 'B': 1, 'E': 1, 'F': 2})

OrderedDict(sorted(d.items(), key=lambda i: i[1])) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
Out:
OrderedDict([('B', 1), ('E', 1), ('A', 2), ('F', 2), ('C', 3)]) ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/4.png)

1

将字典默认设置为整数,默认值为 0。

2

如果项目键不存在,则将其值设置为默认值 0 并添加 1。

3

获取字典 d 中项目的键值对列表,按整数值排序,然后创建一个排序后列表的 OrderedDict

4

OrderedDict 记住了添加到其中的项目的(排序后的)顺序。

您可以在 Python 文档 中获取有关 collections 模块的更多详细信息。

如果您想使用更常规的 JavaScript 库复制一些 Python 的 collections 函数,Underscore(或其功能上完全相同的替代品 Lodash^(20)是一个很好的起点。这些库提供了一些增强的函数式编程工具。让我们快速看看这个非常方便的工具。

下划线

Underscore 可能是继普及的 jQuery 后最受欢迎的 JavaScript 库,为 JavaScript 数据可视化程序员提供了大量的函数式编程工具。使用 Underscore 的最简单方法是使用内容传递网络(CDN)远程加载它(这些加载将由您的浏览器缓存,使常见库非常高效),如下所示:

<script src="https://cdnjs.cloudflare.com/ajax/libs/
 underscore.js/1.13.1/underscore-min.js"></script>

Underscore 有大量有用的函数。例如,有一个 countBy 方法,其作用与刚讨论的 Python 的 collections 计数器相同:

let items = ['F', 'C', 'C', 'A', 'B', 'A', 'C', 'E', 'F'];

_.countBy(items) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
Out:
Object {F: 2, C: 3, A: 2, B: 1, E: 1}

1

现在你知道为什么这个库叫 Underscore 了。

现在我们可以看到,现代 JavaScript 中原生的函数方法(mapreducefilter)以及数组的 forEach 迭代器使得 Underscore 稍显不那么必不可少,但它仍然具有一些非常棒的实用工具来增强原生 JS。稍加链式操作,你可以编写极为简洁但非常强大的代码。Underscore 是我在 JavaScript 中接触函数式编程的入门,这些习惯如今依然令人着迷。请访问它们的网站查看 Underscore 的各种实用工具。

让我们看看 Underscore 在更复杂任务中的应用:

journeys = [
  {period:'morning', times:[44, 34, 56, 31]},
  {period:'evening', times:[35, 33],},
  {period:'morning', times:[33, 29, 35, 41]},
  {period:'evening', times:[24, 45, 27]},
  {period:'morning', times:[18, 23, 28]}
];

let groups = _.groupBy(journeys, 'period');
let mTimes = _.pluck(groups['morning'], 'times');
mTimes = _.flatten(mTimes); ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
let average = function(l){
  let sum = _.reduce(l, function(a,b){return a+b},0);
  return sum/l.length;
};
console.log('Average morning time is ' + average(mTimes));
Out:
Average morning time is 33.81818181818182

1

我们的早晨时间数组([[44, 34, 56, 31], [33...]])需要 展平 成一个单一的数字数组。

函数式数组方法和列表推导式

自从 ECMAScript 5 加入 JavaScript 数组的函数方法以来,我使用 Underscore 的频率大大减少。我认为自那时起我几乎没有再使用传统的 for 循环,考虑到 JS for 循环的丑陋程度,这是一件非常好的事情。

一旦你习惯了函数式处理数组,就很难再考虑回到以前。结合 JS 的匿名函数,它使得编程变得非常流畅和表达力强。这也是方法链在这里显得非常自然的一个领域。让我们看一个高度刻意的例子:

let nums = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

let sum = nums.filter(x => x%2) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
  .map(x => x * x) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
  .reduce((total, current) => total + current, 0); ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)

console.log('Sum of the odd squares is ' + sum);

1

过滤列表以获取奇数(即对 modulus (%) 2 操作返回 1)。

2

map 通过将函数应用于每个成员来生成新列表(即 [1, 3, 5...][1, 9, 25...])。

3

reduce 逐个处理映射后的列表,依次提供当前值(在此例中为求和后的值 total)和项目值(current)。默认情况下,第一个参数(total)的初始值为 0,但在这里我们显式地提供了它作为第二个参数。

Python 强大的列表推导式可以轻松模拟上一个例子:

nums = range(10) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)

odd_squares = [x * x for x in nums if x%2] ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
sum(odd_squares) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
Out:
165

1

Python 有一个方便的内置 range 函数,也可以接受起始、结束和步长(例如,range(2, 8, 2)(2, 4, 6))。

2

if 条件检测 x 的奇数性,并将通过此筛选的数字进行平方并插入列表。

3

Python 还有一个内置且经常使用的 sum 语句。

提示

Python 的列表推导可以使用递归控制结构,比如在迭代项目上应用第二个for/if表达式。虽然这可以创建简洁而强大的代码行,但它违背了 Python 的可读性,我不鼓励其使用。即使简单的列表推导也不够直观,并且,尽管它吸引了我们所有人内心深处的精英黑客,你可能会写出难以理解的代码。

Python 的列表推导在基本过滤和映射方面效果良好。它们确实缺乏 JavaScript 的匿名函数的便利性(这些是完全成熟的,有自己的作用域、控制块、异常处理等),但是对于匿名函数的使用存在争议。例如,它们不可重用,并且由于没有名称,使得异常跟踪和调试变得困难。参见Ultimate Courses获取一些有说服力的论点。尽管如此,对于像 D3 这样的库,用命名函数替换用于设置DOM 属性和属性的小型、一次性匿名函数将会太繁琐,只会增加样板代码。

Python 确实有函数式 lambda 表达式,我们将在下一节中看到,但出于必要性和 JavaScript 的最佳实践,我们可以使用命名函数来增加我们的控制范围。对于我们简单的奇数平方示例,命名函数是一种构思,但请注意,它们增加了列表推导的一目了然的可读性,这在函数变得更复杂时变得更加重要:

items = [1, 2, 3, 4, 5]

def is_odd(x):
    return x%2

def sq(x):
    return x * x

sum([sq(x) for x in items if is_odd(x)])

使用 JavaScript,类似的构思也可以增加可读性,并促进 DRY 代码:^(21)

let isOdd = function(x){ return x%2; };

sum = l.filter(isOdd)
...

使用 Python 的 Lambdas 进行 Map、Reduce 和 Filter

虽然 Python 没有匿名函数,但它有lambdas,这是没有名字的表达式,接受参数。尽管缺少 JavaScript 的匿名函数的花哨和吸引力,这些对于 Python 的函数编程来说是一个强大的补充,特别是与其函数方法结合使用时。

注意

Python 的函数内置(mapreducefilter 方法和 lambda 表达式)历史悠久。众所周知,Python 的创始人希望将它们从语言中删除。对它们的不满导致它们不情愿地被保留下来。随着最近对函数式编程的趋势,这看起来是一件非常好的事情。它们并非完美,但比没有好得多。鉴于 JavaScript 对函数式的强调,它们是利用在该语言中获得的技能的一种好方法。

Python 的 lambda 接受多个参数,并对它们进行操作,使用冒号分隔符定义函数块,与标准 Python 函数非常相似,只是简化到了最基本的部分,并且有一个隐式返回。下面的例子展示了在函数式编程中使用的一些 lambda 表达式:

from functools import reduce # if using Python 3+

nums = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

odds = filter(lambda x: x % 2, nums)
odds_sq = map(lambda x: x * x, odds)
reduce(lambda x, y: x + y, odds_sq) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
Out:
165

1

在这里,reduce 方法向 lambda 函数提供两个参数,lambda 函数使用它们在冒号后返回表达式。

JavaScript 闭包与模块模式

JavaScript 的一个关键概念之一是闭包,它本质上是一个嵌套函数声明,使用在外部(但不是全局)作用域中声明的变量,在函数返回后仍然保持活跃。闭包允许实现许多非常有用的编程模式,是该语言的一个常见特性。

让我们看看闭包可能是最常见的用法之一,也是我们在模块模式中已经看到并利用的用法(示例 2-2):在保持对本质上是私有的成员变量的访问的同时,公开了有限的 API。

一个简单的闭包示例是这个小计数器:

function Counter(inc) {
  let count = 0;
  let add = function() { ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
    count += inc;
    console.log('Current count: ' + count);
  }
  return add;
}

let inc2 = Counter(2); ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
inc2(); ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
Out:
Current count: 2
inc2();
Out:
Current count: 4

1

add 函数可以访问本质上是私有的外部作用域中的 countinc 变量。

2

这返回一个带有闭包变量 count0)和 inc2)的 add 函数。

3

调用 inc2 会调用 add,从而更新封闭的 count 变量。

我们可以扩展 Counter 来添加一个小 API。这种技术是 JavaScript 模块和许多简单库的基础,尤其是在使用基于脚本的 JavaScript 时。^(22) 本质上,它选择性地公开公共方法,同时隐藏私有方法和变量,这在编程界通常被视为良好的实践:

function Counter(inc) {
  let count = 0
  let api = {}
  api.add = function() {
    count += inc
    console.log('Current count: ' + count);
  }
  api.sub = function() {
    count -= inc
    console.log('Current count: ' + count)
  }
  api.reset = function() {
    count = 0;
    console.log('Count reset to 0')
  }

  return api
}

cntr = Counter(3);
cntr.add() // Current count: 3
cntr.add() // Current count: 6
cntr.sub() // Current count: 3
cntr.reset() // Count reset to 0

在 JavaScript 中,闭包有各种各样的用途,我建议你对其有个基本的了解——因为在你开始研究其他人的代码时,你会经常遇到它们。以下是三篇特别好的网络文章,提供了闭包的许多实际用例:

Python 也有闭包,但它们并没有像 JavaScript 那样被广泛使用,也许是因为一些使得代码略显尴尬的怪异之处。尽管如此,这些问题是可以克服的。为了演示这一点,示例 2-7 尝试复制之前的 JavaScript 计数器。

示例 2-7. Python 计数器闭包的初步尝试
def get_counter(inc):
    count = 0
    def add():
        count += inc
        print('Current count: ' + str(count))
    return add

如果你用 get_counter 创建一个计数器(示例 2-7),然后尝试运行它,你会得到一个 UnboundLocalError

cntr = get_counter(2)
cntr()
Out:
...
UnboundLocalError: local variable 'count' referenced before
assignment

有趣的是,尽管我们可以在 add 函数内读取 count 的值(注释掉 count += inc 行来尝试),但尝试改变它会抛出错误。这是因为在 Python 中,尝试将值分配给某些东西会假定它是局部范围的。add 函数中没有 count,因此会抛出错误。

在 Python 3 中,我们可以通过使用 nonlocal 关键字来绕过 示例 2-7 中的错误,告诉 Python count 在一个非局部范围内:

...
def add():
    nonlocal count
    count += inc
...

如果你不得不使用 Python 2+(请尝试升级),我们可以使用一点字典技巧来允许对闭包变量进行修改:

def get_counter(inc):
    vars = {'count': 0}
    def add():
        vars['count'] += inc
        print('Current count: ' + str(vars['count']))
    return add

这个技巧之所以有效,是因为我们没有给 vars 赋予新值,而是改变了一个现有容器,即使它超出了局部范围,这也是完全有效的。

如您所见,通过一点努力,JavaScript 开发者可以将他们的闭包技能转移到 Python 中。用例类似,但 Python 是一种更丰富的语言,具有许多有用的内置功能,可以对同一问题应用更多选项。闭包最常见的用途可能是 Python 中的装饰器。

装饰器本质上是扩展函数实用性的函数包装器,而无需更改函数本身。它们是一个相对高级的概念,但您可以在The Code Ship 网站上找到一个用户友好的介绍。

这就是我精心挑选的一些模式和技巧的总结,我在数据可视化工作中经常使用。你无疑会拥有自己的经验,但我希望这些可以帮助你起步。

速查表

作为一个方便的参考指南,图 2-2 至 2-7 包括了一组速查表,用于在 Python 和 JavaScript 之间转换基本操作。

dpj2 0202

图 2-2. 一些基本语法

dpj2 0203

图 2-3. 布尔值

dpj2 0204

图 2-4. 循环和迭代

dpj2 0205

图 2-5. 条件语句

dpj2 0206

图 2-6. 容器

dpj2 0207

图 2-7. 类和原型

概要

我希望本章已经表明,JavaScript 和 Python 有很多共同的语法,而且两种语言中的大多数常见习语和模式都可以在另一种语言中表达,而不会有太多麻烦。编程的核心——迭代、条件和基本数据操作——在两种语言中都很简单,函数的转换也很直接。如果您能够以任何程度的熟练程度编程其中一种语言,那么学习另一种语言的门槛就很低。这就是这些简单的脚本语言的巨大吸引力,它们有很多共同的遗产。

我提供了一个在数据可视化工作中我经常使用的模式、技巧和习惯。我确信这个列表有它的特殊之处,但我已经尽力满足了明显的要求。

将其视为教程的一部分,也是未来章节的参考。未涵盖的任何内容将在引入时进行讨论。

^(1) 特别讨厌的小坑之一是,Python 使用 pop 来移除列表项,但却使用 append 而不是 push 来添加项。JavaScript 使用 push 来添加项,而 append 则用于连接数组。

^(2) node.js 的兴起将 JavaScript 扩展到了服务器端。

^(3) 随着像 TensorFlow.jsDanfo.js(基于 TensorFlow 的 JavaScript pandas 替代品)等库的出现,这一情况正在改变,但 JS 仍远远落后于 Python、R 等。

^(4) 以在服务器上运行 Python 解释器为代价。

^(5) 通过 HTTP 在网络上传输 JS 脚本的限制在很大程度上造成了这种情况。

^(6) 通过使用 ECMAScript 5 的 'use strict' 指令,可以消除遗漏 var 的可能性。

^(7) 这实际上是 JavaScript 压缩器为了减小下载网页文件大小而完成的。

^(8) 软制表符与硬制表符的争论引发了激烈的讨论,有很多争执却鲜有建设性意见。PEP-8 规定了使用空格,这对我来说已经足够好了。

^(9) 可能是两个甚至三个空格,但这个数字在整个模块中必须保持一致。

^(10) JavaScript 使用 UTF-16 的普遍假设导致了许多由 bug 引起的痛苦。请参阅 Mathias Bynens 的博文 进行有趣的分析。

^(11) Python 3 中转向 Unicode 字符串是一个重大变化。考虑到通常伴随 Unicode 编码/解码的混乱,值得阅读有关 此方面的一些内容。Python 2 使用字节字符串。

^(12) parseInt 的功能远不止四舍五入。例如,parseInt(*12.5px*) 返回 12,首先去除 px,然后将字符串转换为数字。它还有第二个 radix 参数来指定转换的基数。具体细节请参见 Mozilla 文档

^(13) 因为 JavaScript 中的所有数字都是浮点数,它只支持 53 位整数。使用更大的整数(例如常用的 64 位)可能导致不连续的整数。请参阅这篇 2ality 博文 获取更多信息。

^(14) 这使得遍历它们的属性比可能要棘手一些。查看这个 Stack Overflow 线程以获取更多详情。

^(15) 如果你好奇,Angus Croll 的一篇博客文章对此有一个很好的总结。

^(16) 这是 JavaScript 明显胜过 Python 的一个领域,许多人希望 Python 也能有类似的功能。

^(17) 从 ECMAScript 5 开始,并在所有现代浏览器中可用。

^(18) 我告诉一个有才华的程序员朋友,我面临向 Python 程序员解释原型的挑战,他指出大多数 JavaScript 程序员可能也需要一些指导。这有很多道理,许多 JavaScript 程序员通过 优雅 地使用原型,在边缘情况下进行技巧性的编码来保持高效。

^(19) 这是使用 ECMAScript 5 的 'use strict;' 指令的另一个原因,它引起了这类错误的注意。

^(20) 出于性能原因,这是我的个人选择。

^(21) 不要重复自己(DRY)是一种可靠的编码约定。

^(22) 现代 JavaScript 有适当的模块,可以导入和导出封装的变量。使用这些会有额外的开销,因为它们目前需要一个构建阶段来准备好供浏览器使用。

第三章:使用 Python 读写数据

任何数据可视化专家的基本技能之一是能够移动数据。无论您的数据是在 SQL 数据库中,CSV 文件中,还是其他更奇特的形式中,您都应该能够舒适地读取数据,转换数据,并在需要时将其写入更方便的格式。Python 在这方面的一个强大功能就是使得这样的数据操作变得异常简单。本章的重点是让您迅速掌握我们数据可视化工具链中这一关键部分。

本章既是教程,又是参考资料的一部分,并且后续章节将参考本章的部分内容。如果您了解 Python 数据读写的基础知识,您可以挑选本章的部分内容作为复习。

容易上手

我还记得当年我开始编程(使用像 C 这样的低级语言),数据操作是多么的笨拙。从文件中读取和写入数据是样板式代码、临时 improvisations 等的令人讨厌的混合体。从数据库中读取同样困难,至于序列化数据,回忆起来仍然痛苦。发现 Python 就像是一阵清新的空气。它并非速度的鬼才,但打开一个文件几乎就是可以做到的最简单的事情了:

file = open('data.txt')

那时候,Python 让从文件中读取和写入数据变得令人耳目一新,它复杂的字符串处理功能也使得解析这些文件中的数据变得同样简单。它甚至有一个叫做 Pickle 的神奇模块,可以序列化几乎任何 Python 对象。

近年来,Python 已经在其标准库中添加了强大成熟的模块,使得处理 CSV 和 JSON 文件(Web 数据可视化工作的标准)变得同样容易。还有一些很棒的库可以与 SQL 数据库进行交互,比如 SQLAlchemy,我强烈推荐使用。新型 NoSQL 数据库也得到了很好的服务。MongoDB 是这些新型基于文档的数据库中最流行的,Python 的 PyMongo 库(稍后在本章中演示)使得与它的交互相对轻松。

传递数据

展示如何使用关键数据存储库的一个好方法是在它们之间传递单个数据包,边读取边写入。这将让我们有机会看到数据可视化器使用的关键数据格式和数据库的实际操作。

我们将要传递的数据可能是在 Web 可视化中最常用的,是一组类似于字典的对象列表(见示例 3-1)。这个数据集以JSON 格式传输到浏览器,正如我们将看到的那样,可以轻松地从 Python 字典转换过来。

示例 3-1. 我们的目标数据对象列表
nobel_winners = [
 {'category': 'Physics',
  'name': 'Albert Einstein',
  'nationality': 'Swiss',
  'gender': 'male',
  'year': 1921},
 {'category': 'Physics',
  'name': 'Paul Dirac',
  'nationality': 'British',
  'gender': 'male',
  'year': 1933},
 {'category': 'Chemistry',
  'name': 'Marie Curie',
  'nationality': 'Polish',
  'gender': 'female',
  'year': 1911}
]

我们将从创建一个 CSV 文件开始,以 Python 列表的形式显示示例 3-1,作为打开和写入系统文件的演示。

以下各节假定您处于具有data子目录的工作(根)目录中。您可以从 Python 解释器或文件中运行代码。

使用系统文件

在本节中,我们将从 Python 字典列表(示例 3-1)创建一个 CSV 文件。通常,您会使用csv模块来执行此操作,我们将在此节后演示,因此这只是演示基本的 Python 文件操作的一种方式。

首先,让我们打开一个新文件,使用w作为第二个参数表示我们将向其写入数据。

f = open('data/nobel_winners.csv', 'w')

现在,我们将从nobel_winners字典(示例 3-1)创建我们的 CSV 文件:

cols = nobel_winners[0].keys() ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
cols = sorted(cols) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)

with open('data/nobel_winners.csv', 'w') as f: ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
    f.write(','.join(cols) + '\n') ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/4.png)

    for o in nobel_winners:
        row = [str(o[col]) for col in cols] ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/5.png)
        f.write(','.join(row) + '\n')

1

从第一个对象的键(即['category', 'name', ... ])获取我们的数据列。

2

按字母顺序对列进行排序。

3

使用 Python 的with语句来保证在离开块或发生任何异常时关闭文件。

4

join从字符串列表(这里是cols)创建一个连接字符串,由初始字符串(即“category,name,..”)连接。

5

使用列键创建对象中的列表(nobel_winners)。

现在我们已经创建了我们的 CSV 文件,让我们使用 Python 读取它,确保一切都正确:

with open('data/nobel_winners.csv') as f:
    for line in f.readlines():
        print(line)

Out:
category,name,nationality,gender,year
Physics,Albert Einstein,Swiss,male,1921
Physics,Paul Dirac,British,male,1933
Chemistry,Marie Curie,Polish,female,1911

如前面的输出所示,我们的 CSV 文件格式正确。让我们使用 Python 内置的csv模块首先读取它,然后正确创建 CSV 文件。

CSV、TSV 和行列数据格式

逗号分隔值(CSV)或其制表符分隔的同类(TSV)可能是最普遍的基于文件的数据格式,作为数据可视化者,这些通常是你会收到的形式,用于处理你的数据。能够读取和写入 CSV 文件及其各种古怪的变体,比如以管道或分号分隔,或者使用**代替标准双引号的格式,是一项基本技能;Python 的csv模块能够在这里做几乎所有的繁重工作。让我们通过读写我们的nobel_winners`数据来展示它的功能:

nobel_winners = [
 {'category': 'Physics',
  'name': 'Albert Einstein',
  'nationality': 'Swiss',
  'gender': 'male',
  'year': 1921},
  ...
]

将我们的nobel_winners数据(见示例 3-1)写入 CSV 文件非常简单。csv有一个专门的DictWriter类,它会将我们的字典转换为 CSV 行。我们唯一需要做的显式记录就是写入 CSV 文件的标题,使用我们字典的键作为字段(即“category, name, nationality, gender”):

import csv

with open('data/nobel_winners.csv', 'w') as f:
    fieldnames = nobel_winners[0].keys() ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
    fieldnames = sorted(fieldnames) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
    writer = csv.DictWriter(f, fieldnames=fieldnames)
    writer.writeheader() ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
    for w in nobel_winners:
        writer.writerow(w)

1

您需要明确告知写入器要使用哪些 fieldnames(在本例中是 'category''name' 等键)。

2

我们将 CSV 标题字段按字母顺序排序以提高可读性。

3

写入 CSV 文件的标题(“category, name,…​”)。

您可能经常读取 CSV 文件,而不是写入它们。^(1) 让我们读取刚刚写入的 nobel_winners.csv 文件。

如果您只想将 csv 作为一个优秀且非常适应的文件行读取器使用,几行代码将生成一个便捷的迭代器,可以将您的 CSV 行作为字符串列表传递:

with open('data/nobel_winners.csv') as f:
    reader = csv.reader(f)
    for row in reader: ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
        print(row)

Out:
['category', 'name', 'nationality', 'gender', 'year']
['Physics', 'Albert Einstein', 'Swiss', 'male', '1921']
['Physics', 'Paul Dirac', 'British', 'male', '1933']
['Chemistry', 'Marie Curie', 'Polish', 'female', '1911']

1

遍历reader对象,消耗文件中的行。

注意数字以字符串形式读取。如果要对其进行数值操作,需要将任何数值列转换为其相应的类型,这种情况下是整数年份。

更方便地消耗 CSV 数据的方法是将行转换为 Python 字典。这种record形式也是我们作为转换目标(list of dict)使用的形式。csv 提供了一个方便的 DictReader 就是为了这个目的:

import csv

with open('data/nobel_winners.csv') as f:
    reader = csv.DictReader(f)
    nobel_winners = list(reader) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)

nobel_winners

Out:
[OrderedDict([('category', 'Physics'),
              ('name', 'Albert Einstein'),
              ('nationality', 'Swiss'),
              ('gender', 'male'),
              ('year', '1921')]),
 OrderedDict([('category', 'Physics'),
              ('name', 'Paul Dirac'),
              ('nationality', 'British'),
              ... ])]

1

将所有 reader 项插入列表中。

正如输出所示,我们只需将 dict 的年份属性转换为整数,就可以使 nobel_winners 符合本章的目标数据(示例 3-1),如下:

for w in nobel_winners:
    w['year'] = int(w['year'])

为了更灵活地创建 Python datetime,我们可以轻松从年份列创建它:

from datetime import datetime

dt = datetime.strptime('1947', '%Y')
dt
# datetime.datetime(1947, 1, 1, 0, 0)

csv读取器不会从你的文件中推断数据类型,而是将所有内容解释为字符串。pandas,Python 中领先的数据处理库,会尝试猜测数据列的正确类型,通常能够成功。我们将在后面专门讲解 pandas 的章节中看到这一点。

csv 有一些有用的参数来帮助解析 CSV 家族的成员:

dialect

默认情况下,'excel';指定了一组特定于方言的参数。excel-tab 是一种偶尔使用的替代方式。

delimiter

文件通常是逗号分隔的,但也可以使用 |:' '

quotechar

默认情况下,使用双引号,但偶尔会使用 |` 替代。

您可以在 在线 Python 文档 中找到完整的 csv 参数集。

现在我们已经成功地使用 csv 模块编写和读取了目标数据,让我们将我们的基于 CSV 的 nobel_winners dict 传递给 json 模块。

JSON

在本节中,我们将使用 Python 的 json 模块编写和读取我们的 nobel_winners 数据。让我们回顾一下我们使用的数据:

nobel_winners = [
 {'category': 'Physics',
  'name': 'Albert Einstein',
  'nationality': 'Swiss',
  'gender': 'male',
  'year': 1921},
  ...
]

对于诸如字符串、整数和浮点数等数据原语,可以使用 Python 字典将其轻松保存(或在 JSON 术语中转储)到 JSON 文件中,使用json模块。dump方法接受一个 Python 容器和一个文件指针,将前者保存到后者:

import json

with open('data/nobel_winners.json', 'w') as f:
     json.dump(nobel_winners, f)

open('data/nobel_winners.json').read()
Out: '[{"category": "Physics", "name": "Albert Einstein",
"gender": "male", "year": 1921,
"nationality": "Swiss"}, {"category": "Physics",
"nationality": "British", "year": 1933, "name": "Paul Dirac",
"gender": "male"}, {"category": "Chemistry", "nationality":
"Polish", "year": 1911, "name": "Marie Curie", "gender":
"female"}]'

读取(或加载)JSON 文件同样简单。我们只需将打开的 JSON 文件传递给json模块的load方法即可:

import json

with open('data/nobel_winners.json') as f:
    nobel_winners = json.load(f)

nobel_winners
Out:
{'category': 'Physics',
  'name': 'Albert Einstein',
  'nationality': 'Swiss',
  'gender': 'male',
  'year': 1921}, ![1
... }]

1

请注意,与 CSV 文件转换不同,年列的整数类型被保留。

json具有方法loadsdumps,它们分别对应于文件访问方法,将 JSON 字符串加载到 Python 容器中,并将 Python 容器转储为 JSON 字符串。

处理日期和时间

尝试将datetime对象转储为json会产生TypeError

from datetime import datetime

json.dumps(datetime.now())
Out:
...
TypeError: datetime.datetime(2021, 9, 13, 10, 25, 52, 586792)
is not JSON serializable

当序列化诸如字符串或数字之类的简单数据类型时,默认的json编码器和解码器效果很好。但对于诸如日期之类的更专门化数据,您需要自己进行编码和解码。这并不像听起来那么难,并且很快就会变得日常。让我们首先看一下如何将您的 Python datetimes编码为明智的 JSON 字符串。

编码 Python 数据中包含datetime的最简单方法是创建一个自定义编码器,就像在示例 3-2 中所示的一样,它作为json.dumps方法的cls参数提供。该编码器依次应用于数据中的每个对象,并将日期或日期时间转换为其 ISO 格式字符串(参见“处理日期、时间和复杂数据”)。

示例 3-2. 将 Python datetime编码为 JSON
import datetime
import json

class JSONDateTimeEncoder(json.JSONEncoder): ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
    def default(self, obj):
        if isinstance(obj, (datetime.date, datetime.datetime)): ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
            return obj.isoformat()
        else:
            return json.JSONEncoder.default(self, obj)

def dumps(obj):
    return json.dumps(obj, cls=JSONDateTimeEncoder) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)

1

为了创建一个定制的日期处理类,需要从JSONEncoder派生子类。

2

测试datetime对象,如果为真,则返回任何日期或日期时间的isoformat(例如,2021-11-16T16:41:14.650802)。

3

使用cls参数来设置自定义日期编码器。

让我们看看我们的新dumps方法如何处理一些datetime数据:

now_str = dumps({'time': datetime.datetime.now()})
now_str
Out:
'{"time": "2021-11-16T16:41:14.650802"}'

time字段已正确转换为 ISO 格式字符串,准备解码为 JavaScript 的Date对象(参见“处理日期、时间和复杂数据”进行演示)。

虽然您可以编写一个通用解码器来处理任意 JSON 文件中的日期字符串,^(2)但这可能并不明智。日期字符串有各种各样的奇特变体,最好手动处理几乎总是已知的数据集。

可靠的 strptime 方法,属于 datetime.datetime 包,非常适合将已知格式的时间字符串转换为 Python 的 datetime 实例:

In [0]: from datetime import datetime

In [1]: time_str = '2021/01/01 12:32:11'

In [2]: dt = datetime.strptime(time_str, '%Y/%m/%d %H:%M:%S') ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)

In [3]: dt
Out[2]: datetime.datetime(2021, 1, 1, 12, 32, 11)

1

strptime 尝试使用各种指令(例如 %Y(带世纪的年份)和 %H(零填充的小时))将时间字符串与格式字符串匹配。如果成功,它会创建一个 Python 的 datetime 实例。请参阅 Python 文档 查看所有可用指令的完整列表。

如果 strptime 被传入一个与其格式不匹配的时间字符串,它会抛出一个便捷的 ValueError

dt = datetime.strptime('1/2/2021 12:32:11', '%Y/%m/%d %H:%M:%S')
-----------------------------------------------------------
ValueError                Traceback (most recent call last)
<ipython-input-111-af657749a9fe> in <module>()
----> 1 dt = datetime.strptime('1/2/2021 12:32:11',\
    '%Y/%m/%d %H:%M:%S')
...
ValueError: time data '1/2/2021 12:32:11' does not match
            format '%Y/%m/%d %H:%M:%S'

因此,要将已知格式的日期字段转换为 datetime,并应用于一个由字典组成的 data 列表,您可以像这样做:

data = [
    {'id': 0, 'date': '2020/02/23 12:59:05'},
    {'id': 1, 'date': '2021/11/02 02:32:00'},
    {'id': 2, 'date': '2021/23/12 09:22:30'},
]

for d in data:
     try:
         d['date'] = datetime.strptime(d['date'],\
           '%Y/%m/%d %H:%M:%S')
     except ValueError:
         print('Oops! - invalid date for ' + repr(d))
# Out:
# Oops! - invalid date for {'id': 2, 'date': '2021/23/12 09:22:30'}

现在我们已经处理了两种最流行的数据文件格式,让我们转向重点,看看如何从 SQL 和 NoSQL 数据库中读取和写入数据。

SQL

对于与 SQL 数据库交互,SQLAlchemy 是最流行且在我看来是最好的 Python 库。它允许您在速度和效率是问题的情况下使用原始 SQL 指令,同时提供一个强大的对象关系映射(ORM),使您能够使用高级、Pythonic API 操作 SQL 表,本质上将其视为 Python 类。

使用 SQL 进行数据读取和写入,同时允许用户将这些数据视为 Python 容器是一个复杂的过程,尽管 SQLAlchemy 比低级 SQL 引擎更加用户友好,但它仍然是一个相当复杂的库。我将在这里介绍基础知识,以我们的数据作为目标,但建议您花点时间阅读关于 SQLAlchemy 的出色文档。让我们先回顾一下我们打算读取和写入的 nobel_winners 数据集:

nobel_winners = [
 {'category': 'Physics',
  'name': 'Albert Einstein',
  'nationality': 'Swiss',
  'gender': 'male',
  'year': 1921},
  ...
]

首先,让我们使用 SQLAlchemy 将目标数据写入 SQLite 文件,开始创建数据库引擎。

创建数据库引擎

在开始 SQLAlchemy 会话时,第一件事是创建一个数据库引擎。该引擎将与所需的数据库建立连接,并对 SQLAlchemy 生成的通用 SQL 指令和返回的数据执行任何所需的转换。

几乎每种流行的数据库都有相应的引擎,还有一个 memory 选项,将数据库保存在 RAM 中,用于快速访问测试。^(3) 这些引擎的伟大之处在于它们是可互换的,这意味着您可以使用方便的基于文件的 SQLite 数据库开发代码,然后通过更改单个配置字符串在生产环境中切换到一些更工业化的选项,如 PostgreSQL。请查看 SQLAlchemy 获取可用引擎的完整列表。

指定数据库 URL 的格式如下:

dialect+driver://username:password@host:port/database

因此,要连接到运行在本地主机上的 'nobel_winners' MySQL 数据库,需要类似以下方式。请注意,在此时 create_engine 并没有真正发出任何 SQL 请求,而只是为执行这些请求设置了框架:^(4)

engine = create_engine(
           'mysql://kyran:mypsswd@localhost/nobel_winners')

我们将使用基于文件的 SQLite 数据库,并将 echo 参数设置为 True,这样 SQLAlchemy 生成的任何 SQL 指令都会输出。请注意冒号后面的三个反斜杠的用法:

from sqlalchemy import create_engine

engine = create_engine(
            'sqlite:///data/nobel_winners.db', echo=True)

SQLAlchemy 提供了多种与数据库交互的方式,但我建议使用更近代的声明式风格,除非有充分理由选择更低级和细粒度的方法。本质上,使用声明式映射,您可以从一个基类子类化您的 Python SQL 表类,并让 SQLAlchemy 自动检查它们的结构和关系。详细信息请参阅 SQLAlchemy

定义数据库表

我们首先使用 declarative_base 创建一个 Base 类。这个基类将用于创建表类,从而让 SQLAlchemy 创建数据库的表结构。您可以使用这些表类以相当 Pythonic 的方式与数据库交互:

from sqlalchemy.ext.declarative import declarative_base

Base = declarative_base()

注意,大多数 SQL 库要求您正式定义表结构。这与无模式 NoSQL 变体如 MongoDB 相反。本章后面我们将看到 Dataset 库,它支持无模式 SQL。

使用这个 Base,我们定义我们的各种表,例如我们的单个 Winner 表。示例 3-3 展示了如何子类化 Base 并使用 SQLAlchemy 的数据类型来定义表结构。请注意 __tablename__ 成员,它将用于命名 SQL 表和作为检索它的关键字,还有可选的自定义 __repr__ 方法,用于在打印表行时使用。

示例 3-3. 定义 SQL 数据库表
from sqlalchemy import Column, Integer, String, Enum
// ...

class Winner(Base):
    __tablename__ = 'winners'
    id = Column(Integer, primary_key=True)
    category = Column(String)
    name = Column(String)
    nationality = Column(String)
    year = Column(Integer)
    gender = Column(Enum('male', 'female'))
    def __repr__(self):
        return "<Winner(name='%s', category='%s', year='%s')>"\
%(self.name, self.category, self.year)

在 示例 3-3 中声明了我们的 Base 子类后,我们使用其 metadatacreate_all 方法和我们的数据库引擎来创建我们的数据库。因为在创建引擎时设置了 echo 参数为 True,所以我们可以从命令行看到 SQLAlchemy 生成的 SQL 指令:

Base.metadata.create_all(engine)

2021-11-16 17:58:34,700 INFO sqlalchemy.engine.Engine BEGIN (implicit)
...
CREATE TABLE winners (
	id INTEGER NOT NULL,
	category VARCHAR,
	name VARCHAR,
	nationality VARCHAR,
	year INTEGER,
	gender VARCHAR(6),
	PRIMARY KEY (id)
)...
2021-11-16 17:58:34,742 INFO sqlalchemy.engine.Engine COMMIT

使用我们新声明的 winners 表,我们可以开始向其中添加获奖者实例。

使用会话添加实例

现在我们已经创建了我们的数据库,我们需要一个会话来进行交互:

from sqlalchemy.orm import sessionmaker

Session = sessionmaker(bind=engine)
session = Session()

现在我们可以使用我们的 Winner 类创建实例和表行,并将它们添加到会话中:

albert = Winner(**nobel_winners[0]) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
session.add(albert)
session.new ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
Out:
IdentitySet([<Winner(name='Albert Einstein', category='Physics',
             year='1921')>])

1

Python 的便捷 ** 操作符将我们的第一个 nobel_winners 成员解包为键值对:(name='Albert Einstein', category='Physics'...)

2

new 是任何已添加到此会话中的项目的集合。

注意所有的数据库插入和删除都是在 Python 中进行的。只有当我们使用 commit 方法时,数据库才会被修改。

提示

尽可能少地提交,允许 SQLAlchemy 在后台完成其工作。当您提交时,SQLAlchemy 应该将您的各种数据库操作总结起来,并以高效的方式进行通信。提交涉及建立数据库握手和协商事务,这通常是一个缓慢的过程,您应尽可能地限制提交,充分利用 SQLAlchemy 的簿记能力。

new 方法所示,我们已将一个 Winner 添加到会话中。我们可以使用 expunge 移除对象,留下一个空的 IdentitySet

session.expunge(albert) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
session.new
Out:
IdentitySet([])

1

从会话中移除实例(还有一个 expunge_all 方法,用于移除会话中添加的所有新对象)。

此时,还没有进行任何数据库插入或删除操作。让我们将我们的 nobel_winners 列表中的所有成员添加到会话并提交到数据库:

winner_rows = [Winner(**w) for w in nobel_winners]
session.add_all(winner_rows)
session.commit()
Out:
INFO:sqlalchemy.engine.base.Engine:BEGIN (implicit)
...
INFO:sqlalchemy.engine.base.Engine:INSERT INTO winners (name,
category, year, nationality, gender) VALUES (?, ?, ?, ?, ?)
INFO:sqlalchemy.engine.base.Engine:('Albert Einstein',
'Physics', 1921, 'Swiss', 'male')
...
INFO:sqlalchemy.engine.base.Engine:COMMIT

现在我们已将我们的 nobel_winners 数据提交到数据库,让我们看看我们可以用它做些什么以及如何在 Example 3-1 中重新创建目标列表。

查询数据库

要访问数据,您可以使用 sessionquery 方法,其结果可以进行过滤、分组和交集操作,允许完整范围的标准 SQL 数据检索。您可以在 SQLAlchemy 文档 中查看可用的查询方法。现在,让我快速浏览一下我们诺贝尔数据集上一些最常见的查询。

让我们首先计算一下我们获奖者表中的行数:

session.query(Winner).count()
Out:
3

接下来,让我们检索所有瑞士获奖者:

result = session.query(Winner).filter_by(nationality='Swiss') ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
list(result)
Out:
[<Winner(name='Albert Einstein', category='Physics',\
  year='1921')>]

1

filter_by 使用关键字表达式;它的 SQL 表达式对应物是 filter ——例如,filter(Winner.nationality == *Swiss*)。请注意在 filter 中使用的布尔等价 ==

现在让我们获取所有非瑞士物理学获奖者:

result = session.query(Winner).filter(\
             Winner.category == 'Physics', \
             Winner.nationality != 'Swiss')
list(result)
Out:
[<Winner(name='Paul Dirac', category='Physics', year='1933')>]

根据 ID 号获取行的方法如下:

session.query(Winner).get(3)
Out:
<Winner(name='Marie Curie', category='Chemistry', year='1911')>

现在让我们按年份排序获取获奖者:

res = session.query(Winner).order_by('year')
list(res)
Out:
[<Winner(name='Marie Curie', category='Chemistry',\
year='1911')>,
 <Winner(name='Albert Einstein', category='Physics',\
year='1921')>,
 <Winner(name='Paul Dirac', category='Physics', year='1933')>]

当我们通过会话查询返回的 Winner 对象转换为 Python dict 时,重建我们的目标列表需要一些努力。让我们写一个小函数来创建一个从 SQLAlchemy 类到 dict 的映射。我们将使用一些表内省来获取列标签(参见 Example 3-4)。

Example 3-4. 将 SQLAlchemy 实例转换为 dict
def inst_to_dict(inst, delete_id=True):
    dat = {}
    for column in inst.__table__.columns: ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
        dat[column.name] = getattr(inst, column.name)
    if delete_id:
        dat.pop('id') ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
    return dat

1

访问实例的表类以获取列对象的列表。

2

如果 delete_id 为 true,则删除 SQL 主 ID 字段。

我们可以使用 示例 3-4 重建我们的 nobel_winners 目标列表:

winner_rows = session.query(Winner)
nobel_winners = [inst_to_dict(w) for w in winner_rows]
nobel_winners
Out:
[{'category': 'Physics',
  'name': 'Albert Einstein',
  'nationality': 'Swiss',
  'gender': 'male',
  'year': 1921},
  ...
]

你可以通过更改其反映对象的属性轻松更新数据库行:

marie = session.query(Winner).get(3) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
marie.nationality = 'French'
session.dirty ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
Out:
IdentitySet([<Winner(name='Marie Curie', category='Chemistry',
year='1911')>])

1

获取 Marie Curie,国籍为波兰。

2

dirty 显示尚未提交到数据库的任何已更改实例。

让我们提交 Marie 的更改,并检查她的国籍是否从波兰变为法国:

session.commit()
Out:
INFO:sqlalchemy.engine.base.Engine:UPDATE winners SET
nationality=? WHERE winners.id = ?
INFO:sqlalchemy.engine.base.Engine:('French', 3)
...

session.dirty
Out:
IdentitySet([])

session.query(Winner).get(3).nationality
Out:
'French'

除了更新数据库行外,你还可以删除查询结果:

session.query(Winner).filter_by(name='Albert Einstein').delete()
Out:
INFO:sqlalchemy.engine.base.Engine:DELETE FROM winners WHERE
winners.name = ?
INFO:sqlalchemy.engine.base.Engine:('Albert Einstein',)
1

list(session.query(Winner))
Out:
[<Winner(name='Paul Dirac', category='Physics', year='1933')>,
 <Winner(name='Marie Curie', category='Chemistry',\
 year='1911')>]

如果需要,你也可以使用声明类的 __table__ 属性删除整个表:

Winner.__table__.drop(engine)

在本节中,我们处理了一个单独的获奖者表,没有任何外键或与其他表的关系,类似于 CSV 或 JSON 文件。SQLAlchemy 提供了相同的方便程度,用于处理多对一、一对多和其他数据库表关系,就像处理使用隐式连接进行基本查询一样,通过为查询提供多个表类或显式使用查询的 join 方法。请查看 SQLAlchemy 文档中的示例以获取更多详细信息。

使用 Dataset 更轻松的 SQL

我最近发现自己经常使用的一个库是 Dataset,这是一个设计用于使与 SQL 数据库的交互比现有的强大工具如 SQLAlchemy 更容易和更符合 Python 风格的模块。 Dataset 尝试提供与无模式 NoSQL 数据库(如 MongoDB)工作时获得的便利程度相同,通过移除许多更传统库要求的形式化样板代码,比如模式定义。Dataset 建立在 SQLAlchemy 之上,这意味着它可以与几乎所有主要数据库一起工作,并且可以利用该领域最佳的库的功能、健壮性和成熟性。让我们看看它如何处理读取和写入我们的目标数据集(来自 示例 3-1)。

让我们使用刚刚创建的 SQLite nobel_winners.db 数据库来测试 Dataset 的功能。首先,我们连接到我们的 SQL 数据库,使用与 SQLAlchemy 相同的 URL/文件格式:

import dataset

db = dataset.connect('sqlite:///data/nobel_winners.db')

要获取我们的获奖者列表,我们从我们的 db 数据库中获取一个表,使用其名称作为键,然后使用不带参数的 find 方法返回所有获奖者:

wtable = db['winners']
winners = wtable.find()
winners = list(winners)
winners
#Out:
#[OrderedDict([(u'id', 1), ('name', 'Albert Einstein'),
# ('category', 'Physics'), ('year', 1921), ('nationality',
# 'Swiss'), ('gender', 'male')]), OrderedDict([('id', 2),
# ('name', 'Paul Dirac'), ('category', 'Physics'),
# ('year', 1933), ('nationality', 'British'), ('gender',
# 'male')]), OrderedDict([('id', 3), ('name', 'Marie
# Curie'), ('category', 'Chemistry'), ('year', 1911),
# ('nationality', 'Polish'), ('gender', 'female')])]

注意,Dataset 的 find 方法返回的实例是 OrderedDict。这些有用的容器扩展了 Python 的 dict 类,并且行为类似于字典,只是它们记住了插入项的顺序,这意味着你可以保证迭代的结果,弹出最后插入的项等操作。这是一个非常方便的附加功能。

提示

对于数据处理者来说,最有用的 Python“内置工具”之一是collections,其中包括数据集的OrderedDictdefaultdictCounter类特别有用。请查看Python 文档中提供的内容。

让我们使用 Dataset 重新创建我们的获奖者表,首先删除现有表:

wtable = db['winners']
wtable.drop()

wtable = db['winners']
wtable.find()
#Out:
#[]

要重新创建我们删除的获奖者表,我们不需要像 SQLAlchemy 那样定义模式(请参阅“定义数据库表”)。Dataset 将从我们添加的数据中推断出模式,并隐式执行所有 SQL 创建。这是在使用基于集合的 NoSQL 数据库时所习惯的方便之一。让我们使用我们的nobel_winners数据集(示例 3-1)来插入一些获奖者字典。我们使用数据库事务和with语句来高效地插入对象,然后提交它们:^(7)

with db as tx: ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
     tx['winners'].insert_many(nobel_winners)

1

使用with语句确保事务tx提交到数据库。

让我们检查一切是否顺利进行:

list(db['winners'].find())
Out:
[OrderedDict([('id', 1), ('name', 'Albert Einstein'),
('category', 'Physics'), ('year', 1921), ('nationality',
'Swiss'), ('gender', 'male')]),
...
]

获奖者已经被正确插入,并且它们的插入顺序被OrderedDict保留。

数据集非常适合基于 SQL 的基本工作,特别是检索您可能希望处理或可视化的数据。对于更高级的操作,它允许您使用query方法进入 SQLAlchemy 的核心 API。

现在我们已经掌握了使用 SQL 数据库的基础知识,让我们看看 Python 如何使得与最流行的 NoSQL 数据库一样轻松。

MongoDB

像 MongoDB 这样以文档为中心的数据存储为数据处理者提供了很多便利。与所有工具一样,NoSQL 数据库有好的和坏的用例。如果您的数据已经经过精炼和处理,并且不预期需要基于优化表连接的 SQL 强大查询语言,那么 MongoDB 可能最初会更容易使用。MongoDB 非常适合 Web 数据可视化,因为它使用二进制 JSON(BSON)作为其数据格式。BSON 是 JSON 的扩展,可以处理二进制数据和datetime对象,并且与 JavaScript 非常兼容。

让我们再次回顾一下我们要写入和读取的目标数据集:

nobel_winners = [
 {'category': 'Physics',
  'name': 'Albert Einstein',
  'nationality': 'Swiss',
  'gender': 'male',
  'year': 1921},
  ...
]

使用 Python 创建 MongoDB 集合只需几行代码:

from pymongo import MongoClient

client = MongoClient() ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
db = client.nobel_prize ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
coll = db.winners ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)

1

创建 Mongo 客户端,使用默认主机和端口。

2

创建或访问nobel_prize数据库。

3

如果获奖者集合存在,则将其检索出来;否则(如我们的情况),它会创建它。

MongoDB 数据库默认在本地主机端口 27017 上运行,但也可能在网络上的任何地方。它们还可以使用可选的用户名和密码。示例 3-5 展示了如何创建一个简单的实用函数来访问我们的数据库,使用标准默认值。

示例 3-5. 访问 MongoDB 数据库
from pymongo import MongoClient

def get_mongo_database(db_name, host='localhost',\
                       port=27017, username=None, password=None):
    """ Get named database from MongoDB with/out authentication """
    # make Mongo connection with/out authentication
    if username and password:
        mongo_uri = 'mongodb://%s:%s@%s/%s'%\ ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
        (username, password, host, db_name)
        conn = MongoClient(mongo_uri)
    else:
        conn = MongoClient(host, port)

    return conn[db_name]

1

我们在 MongoDB URI(统一资源标识符)中指定数据库名称,因为用户可能没有对数据库的通用权限。

现在我们可以创建一个诺贝尔奖数据库并添加我们的目标数据集(示例 3-1)。让我们首先获取一个获奖者集合,使用访问的字符串常量:

db = get_mongo_database(DB_NOBEL_PRIZE)
coll = db[COLL_WINNERS]

插入我们的诺贝尔奖数据集就像变得如此容易:

coll.insert_many(nobel_winners)
coll.find()
Out:
[{'_id': ObjectId('61940b7dc454e79ffb14cd25'),
  'category': 'Physics',
  'name': 'Albert Einstein',
  'nationality': 'Swiss',
  'year': 1921,
  'gender': 'male'},
 {'_id': ObjectId('61940b7dc454e79ffb14cd26'), ... }
 ...]

结果数组的ObjectId可以用于将来的检索,但 MongoDB 已经在我们的nobel_winners列表上留下了自己的印记,添加了一个隐藏的id属性。^(8)

提示

MongoDB 的ObjectId具有相当多的隐藏功能,不仅仅是一个简单的随机标识符。例如,您可以获取ObjectId的生成时间,这使您可以访问一个方便的时间戳:

import bson
oid = bson.ObjectId()
oid.generation_time
Out: datetime.datetime(2015, 11, 4, 15, 43, 23...

MongoDB BSON 文档中找到完整的详细信息。

现在我们已经在我们的获奖者集合中有了一些项目,使用它的find方法非常容易找到它们,使用一个字典查询:

res = coll.find({'category':'Chemistry'})
list(res)
Out:
[{'_id': ObjectId('55f8326f26a7112e547879d6'),
  'category': 'Chemistry',
  'name': 'Marie Curie',
  'nationality': 'Polish',
  'gender': 'female',
  'year': 1911}]

有许多特殊的以美元为前缀的运算符,允许进行复杂的查询。让我们使用$gt(大于)运算符找到 1930 年后的所有获奖者:

res = coll.find({'year': {'$gt': 1930}})
list(res)
Out:
[{'_id': ObjectId('55f8326f26a7112e547879d5'),
  'category': 'Physics',
  'name': 'Paul Dirac',
  'nationality': 'British',
  'gender': 'male',
  'year': 1933}]

您还可以使用布尔表达式,例如,查找所有 1930 年后的获奖者或所有女性获奖者:

res = coll.find({'$or':[{'year': {'$gt': 1930}},\
{'gender':'female'}]})
list(res)
Out:
[{'_id': ObjectId('55f8326f26a7112e547879d5'),
  'category': 'Physics',
  'name': 'Paul Dirac',
  'nationality': 'British',
  'gender': 'male',
  'year': 1933},
 {'_id': ObjectId('55f8326f26a7112e547879d6'),
  'category': 'Chemistry',
  'name': 'Marie Curie',
  'nationality': 'Polish',
  'gender': 'female',
  'year': 1911}]

您可以在MongoDB 文档中找到可用查询表达式的完整列表。

作为最终测试,让我们将我们的新获奖者集合转换回 Python 字典列表。我们将为此创建一个实用函数:

def mongo_coll_to_dicts(dbname='test', collname='test',\
                        query={}, del_id=True, **kw): ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)

    db = get_mongo_database(dbname, **kw)
    res = list(db[collname].find(query))

    if del_id:
        for r in res:
            r.pop('_id')

    return res

1

一个空的查询字典 {}将在集合中找到所有文档。del_id是一个删除默认情况下项目中的 MongoDBObjectId的标志。

现在我们可以创建我们的目标数据集:

mongo_coll_to_dicts(DB_NOBEL_PRIZE, COLL_WINNERS)
Out:
[{'category': 'Physics',
  'name': 'Albert Einstein',
  'nationality': 'Swiss',
  'gender': 'male',
  'year': 1921},
  ...
]

MongoDB 的无模式数据库非常适合独自工作或小团队快速原型设计。可能会到达一个点,尤其是对于大型代码库,当正式模式成为一个有用的参考和理智检查时;在选择数据模型时,文档形式可以轻松适应的便利是一个优点。能够将 Python 字典作为查询传递给 PyMongo 并且可以访问客户端生成的ObjectId是其他一些便利的例子。

我们现在已经通过所有必需的文件格式和数据库传递了示例 3-1 中的nobel_winners数据。让我们考虑处理日期和时间之前的特殊情况。

处理日期、时间和复杂数据

舒适地处理日期和时间是数据可视化工作的基础,但可能会相当棘手。有许多方法可以将日期或日期时间表示为字符串,每种方法都需要单独的编码或解码。因此,在自己的工作中选择一个格式并鼓励其他人也这样做是很好的。我建议使用 国际标准化组织(ISO)8601 时间格式 作为你的日期和时间的字符串表示,并使用 协调世界时(UTC)形式。^(9) 以下是几个 ISO 8601 日期和日期时间字符串的示例:

2021-09-23 日期(Python/C 格式代码 '%Y-%m-%d'
2021-09-23T16:32:35Z 一个 UTC(时间后加上 Z)日期和时间('T%H:%M:%S'
2021-09-23T16:32+02:00 与协调世界时(UTC)相比为正两小时的时区偏移量(+02:00)(例如,中欧时间)

注意准备处理不同时区的重要性。时区并不总是在经线上(参见 维基百科的时区条目),而通常推导出准确的时间的最佳方式是使用 UTC 时间加上地理位置。

ISO 8601 是 JavaScript 使用的标准,也很容易在 Python 中使用。作为网络数据可视化者,我们的主要关注点是创建一个字符串表示,该表示可以在 Python 和 JavaScript 之间通过 JSON 传递,并在两端轻松地编码和解码。

让我们以一个 Python datetime 的形式取一个日期和时间,将其转换为字符串,然后看看该字符串如何被 JavaScript 使用。

首先,我们生成我们的 Python datetime

from datetime import datetime

d = datetime.now()
d.isoformat()
Out:
'2021-11-16T22:55:48.738105'

然后,这个字符串可以保存到 JSON 或 CSV 中,被 JavaScript 读取,并用于创建一个 Date 对象:

// JavaScript
d = new Date('2021-11-16T22:55:48.738105')
> Tue Nov 16 2021 22:55:48 GMT+0000 (Greenwich Mean Time)

我们可以使用 toISOString 方法将日期时间返回为 ISO 8601 字符串形式:

// JavaScript
d.toISOString()
> '2021-11-16T22:55:48.738Z'

最后,我们可以将字符串读取回 Python。

如果你知道你正在处理的是 ISO 格式的时间字符串,Python 的 dateutil 模块应该可以胜任这个工作。^(10) 但你可能希望对结果进行一些合理性检查:

from dateutil import parser

d = parser.parse('2021-11-16T22:55:48.738Z')
d
Out:
datetime.datetime(2021, 11, 16, 22, 55, 48, 738000,\
tzinfo=tzutc())

请注意,从 Python 到 JavaScript 再返回时,我们丢失了一些分辨率,后者处理的是毫秒,而不是微秒。在任何数据可视化工作中,这不太可能成为问题,但需要记住,以防出现一些奇怪的时间错误。

摘要

本章旨在使您能够舒适地使用 Python 在各种文件格式和数据库之间传输数据,这是数据可视化者可能会遇到的。有效和高效地使用数据库是一个需要一段时间学习的技能,但现在您应该对大多数数据可视化用例的基本读写感到满意。

现在我们已经为我们的数据可视化工具链提供了重要的润滑剂,让我们先快速掌握一下你在后面章节中所需的基本网络开发技能。

^(1) 我建议您将 JSON 作为首选数据格式,而不是 CSV。

^(2) Python 模块dateutil有一个解析器,可以合理地解析大多数日期和时间,可能是此操作的良好基础。

^(3) 值得注意的是,在测试和生产环境中使用不同的数据库配置可能是个坏主意。

^(4) 查看关于SQLAlchemy的详细信息,了解延迟初始化

^(5) 这假设数据库尚不存在。如果存在,则将使用Base来创建新的插入和解释检索。

^(6) Dataset 的官方座右铭是“懒人数据库”。它不是标准 Anaconda 包的一部分,因此您需要通过命令行使用pip进行安装:$ pip install dataset

^(7) 详细了解如何使用事务来分组更新,请参阅此文档

^(8) MongoDB 的一个很酷的特性是ObjectId在客户端生成,无需查询数据库获取它们。

^(9) 要从 UTC 获取实际本地时间,可以存储时区偏移量或更好地从地理坐标派生;这是因为时区并不完全按经度线精确地遵循。

^(10) 要安装,只需运行pip install python-dateutildateutil是 Python 的datetime的一个相当强大的扩展;详细信息请查看Read the Docs

第四章:Webdev 101

本章介绍了核心的网页开发知识,这些知识将帮助你理解你要抓取数据的网页,并结构化那些你想要作为 JavaScript 可视化骨架传递的网页。正如你将看到的,对现代网页开发来说,一点点知识就可以走很长的路,特别是当你的重点是构建独立的可视化而不是整个网站(详见“单页面应用”获取更多详情)。

通常情况下,这章既是参考,又是教程。这里可能有您已经了解的内容,所以可以自由跳过,直接阅读新材料。

大局观

谦逊的网页,作为万维网(WWW)—人类使用的互联网的一部分—的基本构建块,由各种类型的文件构成。除了多媒体文件(图片、视频、声音等),关键元素是文本,由超文本标记语言(HTML)、层叠样式表(CSS)和 JavaScript 组成。这三者,连同任何必要的数据文件,使用超文本传输协议(HTTP)传递,并用于构建您在浏览器窗口中看到和交互的页面,这由文档对象模型(DOM)描述,一个您的内容悬挂的分层树。了解这些元素如何互动是构建现代 Web 可视化的重要基础,而本章的目的就是让您迅速掌握这些知识。

Web 开发是一个广阔的领域,这里的目标不是让您成为一个全面的 Web 开发人员。我假设您希望尽可能地减少必须进行的 Web 开发工作量,只专注于构建现代可视化所需的部分。为了构建像d3js.org展示的那种可视化效果,发表在纽约时报上,或者集成在基本交互式数据仪表板中,实际上您需要的 Web 开发技能相当有限。您的工作成果应该能够被专门负责此类工作的人轻松添加到更大的网站中。对于小型个人网站,将可视化内容整合进去也相当容易。

单页面应用

单页面应用(SPAs)是使用 JavaScript 动态组装的 Web 应用程序(或整个网站),通常基于轻量级 HTML 骨架和可以使用类和 ID 属性动态应用的 CSS 样式构建。许多现代数据可视化项目符合此描述,包括本书所构建的诺贝尔奖可视化项目。

通常是自包含的,SPA 的根目录可以轻松地整合到现有网站中或独立运行,只需一个像 Apache 或 NGINX 这样的 HTTP 服务器。

将我们的数据可视化视为单页应用程序(SPA),可以减少 JavaScript 可视化的 Web 开发方面的认知负担,让我们专注于编程挑战。在网上发布可视化仍然需要的技能相当基础且很快就会摊销。通常这将是其他人的工作。

工具配置

正如你将看到的,制作现代数据可视化所需的 Web 开发并不比一个体面的文本编辑器、现代浏览器和终端(图 4-1)更多。我将介绍我认为适合 Web 开发准备的最低要求编辑器以及非必需但不错的功能。

我选择的浏览器开发工具是Chrome 的 Web 开发者工具套件,在所有平台上都免费提供。它具有许多选项卡分隔的功能,其中我将在本章中介绍以下内容:

  • 元素选项卡,允许您探索 Web 页面的结构、其 HTML 内容、CSS 样式和 DOM 呈现

  • 资源选项卡,大多数 JavaScript 调试将在这里进行

你将需要一个终端用于输出、启动本地 Web 服务器,也许还要用 IPython 解释器草拟一些想法。最近我倾向于使用基于浏览器的Jupyter 笔记本作为我的 Python 数据可视化“草图本”,其主要优势之一是会话以笔记本(.ipynb 文件)的形式持久化,你可以在以后的日期重新启动会话。你还可以通过内嵌的图表迭代地探索数据。我们将在第三部分中充分利用它。

dpj2 0401

图 4-1. 主要的 Web 开发工具

在谈论你需要什么之前,让我们先来谈谈在开始时你不需要的一些事情,顺便打消一些迷思。

IDE、框架和工具的神话

那些准备学习 JavaScript 的人普遍认为,在网络编程中需要复杂的工具集,主要是企业及其他编码人员广泛使用的智能开发环境(IDE)。这可能昂贵且具有陡峭的学习曲线。好消息是,你只需一个体面的文本编辑器,就可以创建专业水平的 Web 数据可视化。事实上,直到你开始涉足现代 JavaScript 框架(我建议在你掌握 Web 开发技能之前先暂缓),IDE 并不会带来太多优势,而且通常性能较差。更令人振奋的消息是,免费且轻量级的Visual Studio Code IDE (VSCode) 已成为 Web 开发的事实标准。如果你已经在使用 VSCode,或者想要一些额外的功能,它是跟随本书的一个良好工具。

还有一个普遍的神话,即在不使用任何框架的情况下,无法在 JavaScript 中提高生产力。(1) 目前,许多这些框架正在争夺 JS 生态系统的控制权,其中大多数由创建它们的各种巨大公司赞助。这些框架来去匆匆,我的建议是,任何刚开始学习 JavaScript 的人都应该完全忽略它们,而是在发展核心技能的同时使用小型、定向的库,比如 jQuery 生态系统中的库或 Underscore 的函数式编程扩展,看看在需要我行我素框架之前你能走多远。只有在有明确现实需求的情况下,才锁定到框架中,而不是因为当前的 JS 集体思维正在疯狂地吹捧它有多么好。(2) 另一个重要的考虑因素是,D3,主要的 Web 数据可视化库,实际上与我所知道的任何较大的框架都不太兼容,特别是那些想要控制 DOM 的框架。使 D3 符合框架的要求是一种高级技能。

如果您逗留在 Webdev 论坛、Reddit 列表和 Stack Overflow 上,您会发现有大量工具不断争相吸引注意。其中包括 JS+CSS 缩小器和监视器,用于在开发过程中自动检测文件更改并重新加载网页等。虽然其中有一些是有用的,但根据我的经验,有很多工具可能比它们在生产力上节省的时间更花费时间。重申一下,您可以在没有这些工具的情况下非常高效地工作,只有在迫切需要解决问题时才应该使用其中之一。有些工具是值得保留的,但只有极少数对于数据可视化工作是必不可少的。

一个文本编辑工具

在您的 Webdev 工具中,首要的是一个您感觉舒适且至少可以对多种语言进行语法高亮的文本编辑器,例如 HTML、CSS、JavaScript 和 Python。您可以使用一个普通的、没有语法高亮的编辑器,但从长远来看,这将证明是一种痛苦。诸如语法高亮、代码检查、智能缩进等功能,可以极大地减轻编程过程中的认知负担,以至于我认为它们的缺失是一种限制因素。这些是我对文本编辑器的最低要求:

  • 对您使用的所有语言进行语法高亮显示

  • 可配置的缩进级别和类型,适用于各种语言(例如,Python 4 个软制表符,JavaScript 2 个软制表符)

  • 多窗口/窗格/选项卡,以便轻松导航您的代码库周围

  • 一个体面的代码检查工具(见图 4-2)

如果您使用的是相对高级的文本编辑器,则上述所有功能应该是标准配置,除了代码检查可能需要一些配置外。

dpj2 0402

图 4-2. 运行的代码检查程序持续分析 JavaScript,以红色突出显示语法错误,并在错误行左侧添加一个!

带有开发工具的浏览器

现代 Web 开发中完整的 IDE 不那么重要的一个原因是,最佳调试位置是在 Web 浏览器本身,而这种环境的变化速度如此之快,以至于任何试图模拟该上下文的 IDE 都将面临艰巨的任务。此外,现代 Web 浏览器已经发展出强大的一套调试和开发工具。其中最好的工具之一是Chrome DevTools,提供了大量功能,从复杂的(对 Pythonista 来说肯定如此)调试(参数断点、变量监视等)到内存和处理器优化分析,设备仿真(想知道您的网页在智能手机或平板电脑上的显示效果?)等等。Chrome DevTools 是我选择的调试器,并将在本书中使用。像本书涵盖的所有内容一样,它是免费使用的。

终端或命令提示符

终端或命令行是您启动各种服务器和可能输出有用日志信息的地方。它还是您尝试 Python 模块或运行 Python 解释器的地方(在许多方面,IPython 是最好的)。

创建网页

典型 Web 可视化有四个要素:

  • 一个 HTML 框架,其中包含我们的程序化可视化的占位符

  • 层叠样式表(CSS),定义外观和感觉(例如,边框宽度、颜色、字体大小、内容块的放置)

  • JavaScript 用于构建可视化

  • 要转换的数据

这三者中的前三个只是文本文件,使用我们最喜爱的编辑器创建,并由 Web 服务器传递到浏览器(见第十二章)。让我们逐个来看。

使用 HTTP 提供页面

用于制作特定网页(及任何相关数据文件、多媒体等)的 HTML、CSS 和 JS 文件的传递是通过超文本传输协议在服务器和浏览器之间进行协商的。HTTP 提供了许多方法,其中最常用的是 GET,它请求一个 Web 资源,在一切顺利时从服务器检索数据,否则会抛出错误。我们将使用 GET,以及 Python 的 requests 模块,在第六章中抓取一些网页内容。

要协商由浏览器生成的 HTTP 请求,您将需要一个服务器。在开发中,您可以使用 Python 的内置 Web 服务器(内置电池之一),该服务器是http模块的一部分。您可以在命令行中启动服务器,并可以选择端口号(默认为 8000),如下所示:

$ python -m http.server 8080
Serving HTTP on 0.0.0.0 port 8080 (http://0.0.0.0:8080/) ...

此服务器现在在本地端口 8080 上提供内容。您可以通过在浏览器中输入 URL http://localhost:8080 来访问它提供的网站。

http.server 模块是一个很好的演示工具,但对于许多基本功能来说仍然不足。因此,正如我们将在第四部分中看到的那样,最好掌握像 Flask 这样的适当开发(和生产)服务器(本书的首选服务器)。

DOM

通过 HTTP 发送的 HTML 文件在浏览器端转换为文档对象模型(DOM),JavaScript 可以通过这种编程 DOM 进行调整,因为这种 DOM 是如 D3 等数据可视化库的基础。DOM 是一种树形结构,由层级节点表示,顶级节点为主网页或文档。

本质上,您编写或生成的 HTML 通过浏览器转换为节点的树层次结构,每个节点表示一个 HTML 元素。顶级节点称为文档对象,所有其他节点都以父子方式衍生。以编程方式操作 DOM 是 jQuery 等库的核心,因此理解其运行原理至关重要。了解 DOM 的绝佳方法是使用诸如Chrome DevTools(我推荐的工具集)之类的 Web 工具检查树的分支。

无论您在网页上看到什么,对象状态的记账(显示或隐藏,矩阵转换等)都是通过 DOM 完成的。D3 的强大创新在于直接将数据附加到 DOM,并使用它驱动视觉变化(数据驱动文档)。

HTML 骨架

典型的 Web 可视化使用 HTML 骨架,并使用 JavaScript 在其上构建可视化。

HTML 是用于描述网页内容的语言。最初由物理学家 Tim Berners-Lee 在瑞士 CERN 粒子加速器复杂中工作时于 1980 年首次提出。它使用标签如 <div><img><h> 来结构化页面内容,而 CSS 则用于定义外观和感觉。^(3) HTML5 的出现大大减少了样板代码,但其本质在这三十年间基本保持不变。

完整的 HTML 规范曾涉及许多相当令人困惑的头部标签,但 HTML5 考虑到了更用户友好的极简主义。这基本上是起始模板的最低要求:^(4)

<!DOCTYPE html>
<meta charset="utf-8">
<body>
    <!-- page content -->
</body>

因此,我们只需声明文档 HTML、我们的字符集 8 位 Unicode,并在其下添加一个 <body> 标签,以添加我们的页面内容。这比以前所需的记账工作有了很大的改进,并为创建将成为网页的文档提供了一个非常低的入门门槛。请注意注释标签的形式:<!-- comment -->

更实际地说,我们可能想要添加一些 CSS 和 JavaScript。您可以通过使用 <style><script> 标签直接将两者添加到 HTML 文档中,如下所示:

<!DOCTYPE html>
<meta charset="utf-8">
<style>
/* CSS */
</style>
<body>
    <!-- page content -->
    <script>
    // JavaScript...
    </script>
</body>

这种单页面 HTML 表单通常在示例中使用,比如在 d3js.org 上的可视化。在演示代码或跟踪文件时,使用单页面非常方便,但通常建议将 HTML、CSS 和 JavaScript 元素分开存放。除了随着代码库变大更容易导航之外,这里的重要优势是可以充分利用编辑器的特定语言增强功能,如完整的语法突出显示和代码检查(实质上是即时语法检查)。尽管一些编辑器和库声称可以处理嵌入式 CSS 和 JavaScript,但我还没有找到一个合适的。

要使用 CSS 和 JavaScript 文件,只需在 HTML 中包含它们,使用 <link><script> 标签,就像这样:

<!DOCTYPE html>
<meta charset="utf-8">
<link rel="stylesheet" href="style.css" />
<body>
    <!-- page content -->
    <script type="text/javascript" src="script.js"></script>
</body>

标记内容

可视化通常使用少量可用的 HTML 标签,通常通过将元素附加到 DOM 树来程序化构建页面。

最常见的标签是 <div>,标记了一个内容块。<div> 可以包含其他 <div>,允许树形层次结构,其分支用于元素选择和传播用户界面(UI)事件,如鼠标点击。这里是一个简单的 <div> 层次结构:

<div id="my-chart-wrapper" class="chart-holder dev">
    <div id="my-chart" class="bar chart">
         this is a placeholder, with parent #my-chart-wrapper
    </div>
</div>

注意使用 idclass 属性。这些在选择 DOM 元素和应用 CSS 样式时使用。ID 是唯一标识符;每个元素应该只有一个,每页只能有一个特定的 ID 出现一次。类可以应用于多个元素,允许批量选择,每个元素可以有多个类。

对于文本内容,主要使用的标签是 <p><h*><br>。你将经常使用它们。此代码生成了图 4-3 Figure 4-3:

<h2>A Level-2 Header</h2>
<p>A paragraph of body text with a line break here..</br>
and a second paragraph...</p>

dpj2 0403

图 4-3. 一个 h2 标题和文本

标题标签按大小倒序排列,从最大的 <h1> 开始。

<div><h*><p> 被称为块元素。它们通常以新行开始和结束。另一类标签是内联元素,它们在显示时不会有换行符。像 <img> 图像、<a> 超链接和 <td> 表格单元格就属于这一类,还包括 <span> 标签用于内联文本:

<div id="inline-examples">
    <img src="path/to/image.png" id="prettypic"> ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
    <p>This is a <a href="link-url">link</a> to <span class="url">link-url</span></p> ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
</div>

1

注意,对于图片,我们不需要闭合标签。

2

文本中 span 和 link 是连续的。

其他有用的标签包括有序 <ol> 和无序 <ul> 列表:

<div style="display: flex; gap: 50px"> ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
  <div>
    <h3>Ordered (ol) list</h3>
    <ol>
      <li>First Item</li>
      <li>Second Item</li>
    </ol>
  </div>
  <div>
    <h3>Unordered (ul) list</h3>
    <ul>
      <li>First Item</li>
      <li>Second Item</li>
    </ul>
  </div>
</div>

1

在这里我们直接在 div 标签上应用 CSS 样式(内联)。查看 “使用 Flex 定位和调整容器大小” 来介绍 flex 显示属性。

图 4-4 显示了渲染后的列表。

dpj2 0404

图 4-4. HTML 列表

HTML 还有一个专用的 <table> 标签,如果你想在可视化中呈现原始数据很有用。这段 HTML 生成了图 4-5 的标题和行 Figure 4-5:

 <table id="chart-data">
  <tr> ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
    <th>Name</th>
    <th>Category</th>
    <th>Country</th>
  </tr>
  <tr> ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
    <td>Albert Einstein</td>
    <td>Physics</td>
    <td>Switzerland</td>
  </tr>
</table>

1

表头行

2

数据的第一行

dpj2 0405

图 4-5. 一个 HTML 表格

当您制作网络可视化时,前面标签最常用的是文本标签,它们提供说明、信息框等。但是我们的 JavaScript 工作的核心可能是构建基于可伸缩矢量图形(SVG)<svg><canvas>标签的 DOM 分支。在大多数现代浏览器上,<canvas>标签还支持 3D WebGL 上下文,允许在页面中嵌入 OpenGL 可视化。^(5)

我们将处理本书的焦点 SVG,这是强大的 D3 库使用的格式,在“可缩放矢量图形”中看看我们如何为我们的内容块添加样式。

CSS

CSS,即层叠样式表,是一种描述网页外观和感觉的语言。虽然您可以将样式属性硬编码到 HTML 中,但通常认为这是不良实践。^(6) 最好的方法是为标签打上idclass,然后在样式表中使用它来应用样式。

CSS 中的关键字是层叠。CSS 遵循优先规则,因此在冲突的情况下,最新的样式会覆盖先前的样式。这意味着包含表的顺序很重要。通常情况下,您希望样式表加载最后,以便可以覆盖浏览器默认样式和任何使用的库定义的样式。

图 4-6 展示了如何使用 CSS 对 HTML 元素应用样式。首先,使用井号(#)选择具有唯一 ID 的元素,使用点号(.)选择类的成员。然后定义一个或多个属性/值对。注意,font-family属性可以是一个优先级顺序列表。在这里,我们希望浏览器默认的font-familyserif(有衬线的),替换为更现代的sans-serif,首选为Helvetica Neue

dpj2 0406

图 4-6. 使用 CSS 为页面添加样式

理解 CSS 优先规则是成功应用样式的关键。简而言之,顺序是:

  1. 在 CSS 属性后面添加!important会覆盖所有。

  2. 越具体越好(例如,ID 优先于类)。

  3. 声明的顺序:最后的声明优先,遵循12

所以,例如,假设我们有一个具有alert类的<span>

<span class="alert" id="special-alert">
something to be alerted to</span>

在我们的 style.css 文件中添加以下内容将使警报文本变为红色和粗体:

.alert { font-weight:bold; color:red }

如果我们然后将其添加到 style.css 中,ID 颜色黑色将覆盖类颜色红色,而类font-weight保持粗体:

#special-alert {background: yellow; color:black}

要强制警报颜色为红色,我们可以使用!important指令:^(7)

.alert { font-weight:bold; color:red !important }

如果我们在 style.css 后添加另一个样式表 style2.css

<link rel="stylesheet" href="style.css" type="text/css" />
<link rel="stylesheet" href="style2.css" type="text/css" />

style2.css 包含以下内容:

.alert { font-weight:normal }

然后警报的font-weight将恢复为normal,因为新的类样式是最后声明的。

JavaScript

JavaScript 是唯一支持浏览器的编程语言,所有现代浏览器都包含了它的解释器。为了进行任何稍微高级的操作(包括所有现代网络可视化),您应该具备 JavaScript 的基础。TypeScript 是 JavaScript 的超集,提供强类型,并且目前正在获得很大的关注。TypeScript 编译为 JavaScript,并假定您精通 JavaScript。

99% 的编码网络可视化示例,你应该学习的示例,都是用 JavaScript 编写的,而流行的替代方案往往会随着时间的推移而逐渐消失。基本上,对于有趣的网络可视化来说,精通(如果不是掌握)JavaScript 是一个先决条件。

对于 Python 爱好者来说,好消息是一旦你掌握了 JavaScript 的一些比较棘手的怪癖[⁸],JavaScript 其实是一种非常不错的语言。正如我在 第二章 中展示的,JavaScript 和 Python 有很多共同点,通常很容易从一种语言翻译到另一种语言。

数据

用于驱动您的网络可视化所需的数据将由 Web 服务器提供为静态文件(例如 JSON 或 CSV 文件)或通过某种 Web API 动态提供(例如,RESTful APIs),通常从数据库服务器端检索数据。我们将在 第 IV 部分 中涵盖所有这些形式。

尽管过去很多数据以 XML 形式 提供,现代网络可视化主要是关于 JSON,并且在较小程度上也是 CSV 或 TSV 文件。

JSON(JavaScript 对象表示法的缩写)是事实上的网络可视化数据标准,我建议你学会喜欢它。显然,它与 JavaScript 非常兼容,但它的结构也会对 Python 爱好者来说很熟悉。正如我们在 “JSON” 中看到的,用 Python 读取和写入 JSON 数据非常简单。这里有一个 JSON 数据的小例子:

{
  "firstName": "Groucho",
  "lastName": "Marx",
  "siblings": ["Harpo", "Chico", "Gummo", "Zeppo"],
  "nationality": "American",
  "yearOfBirth": 1890
}

Chrome 开发者工具

近年来 JavaScript 引擎的竞争,导致性能大幅提升,与此同时,各种浏览器内置的开发工具也变得越来越复杂。Firefox 的 Firebug 曾经领先一段时间,但是 Chrome 开发者工具 已经超越它,并且不断增加功能。现在你可以用 Chrome 的选项卡工具做很多事情,但在这里我将介绍两个最有用的选项卡,即专注于 HTML+CSS 的 Elements 和专注于 JavaScript 的 Sources。这两者与 Chrome 的开发者控制台相辅相成,如 “JavaScript” 中所示。

元素选项卡

要访问元素选项卡,请从右侧选项菜单中选择更多工具→开发者工具,或使用 Ctrl-Shift-I 键盘快捷键(Mac 中为 Cmd-Option-I)。

图 4-7 展示了元素选项卡的工作原理。您可以使用左侧的放大镜选择页面上的 DOM 元素,并在左侧面板中查看它们的 HTML 分支。右侧面板允许您查看应用于元素的 CSS 样式,并查看任何附加的事件监听器或 DOM 属性。

dpj2 0407

图 4-7. Chrome 开发者工具的元素选项卡

元素选项卡的一个非常酷的功能是,您可以交互地更改元素的 CSS 样式和属性。(9) 这是精细调整数据可视化外观和感觉的好方法。

Chrome 的元素选项卡为探索页面结构提供了一个很好的方式,找出不同元素的定位方式。这是了解如何使用positionfloat属性定位内容块的好方法。看看专家如何应用 CSS 样式是提升您技能并学习一些有用技巧的好方式。

来源选项卡

来源选项卡允许您查看页面中包含的任何 JavaScript。图 4-8 展示了该选项卡的工作方式。在左侧面板中,您可以选择带有嵌入式<script>标签的脚本或 HTML 文件。如图所示,您可以在代码中设置断点,加载页面,然后在中断时查看调用堆栈和任何局部或全局变量。这些断点是参数化的,因此您可以设置它们触发的条件,这在想要捕捉和逐步执行特定配置时非常方便。在中断时,您可以标准地执行进入、退出和跳过函数等操作。

dpj2 0408

图 4-8. Chrome 开发者工具的来源选项卡

当试图调试 JavaScript 时,来源选项卡是一个非常好的资源,大大减少了需要使用控制台日志的次数。事实上,JS 调试曾经是一个主要的痛点,现在几乎成为一种乐趣。

其他工具

Chrome 开发者工具中的这些选项卡功能非常丰富,并且几乎每天都在更新。您可以进行内存和 CPU 时间线和分析,监视您的网络下载,并为不同的形式因素测试您的页面。但作为数据可视化工作者,您将在元素和来源选项卡中花费大部分时间。

带有占位符的基本页面

现在我们已经介绍了网页的主要元素,让我们把它们结合起来。大多数 Web 可视化从 HTML 和 CSS 骨架开始,准备好用一点 JavaScript 加数据来完善它们(参见“单页面应用程序”)。

我们首先需要我们的 HTML 骨架,使用示例 4-1 中的代码。这包括一个<div>内容块树,定义了三个图表元素:页眉、主要部分和侧边栏部分。我们将这个文件保存为index.xhtml

示例 4-1. 文件 index.xhtml,我们的 HTML 骨架
<!DOCTYPE html>
<meta charset="utf-8">

<link rel="stylesheet" href="style.css" type="text/css" />

<body>

  <div id="chart-holder" class="dev">
    <div id="header">
      <h2>A Catchy Title Coming Soon...</h2>
      <p>Some body text describing what this visualization is all
      about and why you should care.</p>
    </div>
    <div id="chart-components">
      <div id="main">
        A placeholder for the main chart.
      </div><div id="sidebar">
        <p>Some useful information about the chart,
          probably changing with user interaction.</p>
      </div>
    </div>
  </div>

  <script src="script.js"></script>
</body>

现在我们已经有了 HTML 骨架,我们想要使用一些 CSS 来进行样式化。这将使用我们内容块的类和 ID 来调整大小、位置、背景颜色等。要应用我们的 CSS,在示例 4-1 中我们引入了一个style.css文件,如示例 4-2 所示。

示例 4-2. style.css 文件,提供我们的 CSS 样式
body {
    background: #ccc;
    font-family: Sans-serif;
}

div.dev { ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
    border: solid 1px red;
}

div.dev div {
    border: dashed 1px green;
}

div#chart-holder {
    width: 600px;
    background :white;
    margin: auto;
    font-size :16px;
}

div#chart-components {
    height :400px;
    position :relative; ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
}

div#main, div#sidebar {
    position: absolute; ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
}

div#main {
    width: 75%;
    height: 100%;
    background: #eee;
}

div#sidebar {
    right: 0; ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/4.png)
    width: 25%;
    height: 100%;
}

1

这个dev类是查看任何可视块边框的便捷方法,对于可视化工作非常有用。

2

使chart-components成为相对父级。

3

使mainsidebar相对于chart-components定位。

4

将此块与chart-components的右墙对齐。

我们使用绝对定位的主要和侧边栏图表元素(示例 4-2)。有各种各样的方法来使用 CSS 定位内容块,但绝对定位可以明确控制它们的位置,这是如果你想要达到完美外观必须做的。

在指定了chart-components容器的大小之后,mainsidebar子元素使用其父元素的百分比进行了大小调整和定位。这意味着对chart-components大小的任何更改都将反映在其子元素中。

使用我们定义的 HTML 和 CSS,我们可以通过在包含在示例 4-1 和 4-2 中定义的index.xhtmlstyle.css文件的项目目录中启动 Python 的单行 HTTP 服务器来检查骨架,如下所示:

$ python -m http.server 8000
Serving HTTP on 0.0.0.0 port 8000 ...

图 4-9 显示了打开元素选项卡的结果页面,显示了页面的 DOM 树。

现在图表的内容块已经正确地定位和调整大小,准备使用 JavaScript 添加一些引人入胜的内容。

dpj2 0409

图 4-9. 构建基本网页

使用 Flex 定位和调整大小的容器

历史上,使用 CSS 定位和调整内容(通常为 <div> 容器)有点像黑魔法。并没有帮助的是,有许多跨浏览器的不兼容性和对什么构成填充或边距的争议。但即使考虑到这一点,所使用的 CSS 属性似乎也相当随意。通常,实现看似完全合理的定位或大小野心的方法,结果涉及到深奥的 CSS 知识,隐藏在 Stack Overflow 帖子的深处。一个例子是将 div 在水平和垂直方向上居中[¹¹]。这一切都随着 CSS flex-box 的出现而改变,它使用一些强大的新 CSS 属性几乎提供了您所需要的所有大小和定位。

Flex-boxes 并不完全是一种 CSS 属性来统治它们所有——前面章节展示的绝对定位仍然有其用处,特别是在数据可视化方面——但它们是一组非常强大的属性,往往是实现特定定位/大小任务的最简单、有时是唯一的方法。过去需要 CSS 专业知识的效果现在已经非常容易掌握,而且 flex-boxes 与可变屏幕比例非常兼容——flex 的威力。考虑到这一点,让我们看看基本的 flex 属性能做些什么。

首先,我们将使用一些 HTML 创建一个带有三个子 div(框)的容器 div。子框将具有类 box,并带有一个 ID 以便应用特定的 CSS:

<div class="container" id="top-container">
  <div class="box" id="box1">box 1</div>
  <div class="box" id="box2">box 2</div>
  <div class="box" id="box3">box 3</div>
</div>

最初的 CSS 给容器一个红色边框、宽度和高度(600x400)。框的宽度和高度均为 100 像素(80 像素加上 10 像素的填充)并带有绿色边框。一个新颖的 CSS 属性是容器的 display: flex,它建立了一个 flex 显示上下文。这样做的结果可以在 图 4-10(display: flex)中看到,显示的框以行而不是默认的列呈现,其中每个框占据自己的行:

.container {
  display: flex;
  width: 600px;
  height: 400px;
  border: 2px solid red;
}

.box {
  border: 2px solid green;
  font-size: 28px;
  padding: 10px;
  width: 80px;
  height: 80px;
}

dpj2 0410

图 4-10. 使用 flex-boxes 进行定位和调整大小

Flex 显示根据子元素的 flex 属性响应地扩展其大小以适应可用空间。如果我们使框具有弹性,它们将响应性地扩展以填充容器行。图 4-10(flex-direction: row)显示了结果。请注意,flex 属性会覆盖框的宽度属性,使其能够扩展:

.box {
  /* ... */
  flex: 1;
}

flex-direction 属性默认为 row。通过将其设置为 column,可以将框放置在列中,并覆盖高度属性,使其能够扩展以适应容器的高度。图 4-10(方向列)显示了结果:

.container {
  /* ... */
  flex-direction: column;
}

从框中删除或注释掉宽度和高度属性会使它们完全灵活,能够在水平和垂直方向上扩展,生成 图 4-10(全灵活):

.box {
  /* ... */
  /* width: 80px;
 height: 80px; */
  flex: 1;
}

如果你想颠倒 flex 盒子的顺序,有 row-reversecolumn-reverseflex-direction。图 4-10 (列逆序) 显示了颠倒列的结果:

.container {
  /* ... */
  flex-direction: column-reverse;
}

盒子的 flex 属性值表示一个大小权重。最初,所有的盒子都有一个权重为一,这使它们的大小相等。如果我们给第一个盒子一个权重为二,它将占据在指定的行或列方向上可用空间的一半(2 / (1 + 1 + 2))。图 4-10 (#box1 flex 2) 显示了增加 box1flex 值的结果:

#box1 {
  flex: 2;
}

如果我们将盒子的高度和宽度限制(包括填充)返回到 100 像素,并移除它们的 flex 属性,我们可以展示 flex 布局定位的强大功能。我们还需要从 box1 中删除 flex 指令:

.box {
  width: 80px;
  height: 80px;
  /* flex: 1; */
}

#box1 {
  /* flex: 2; */
}

对于固定大小的内容,flex 布局具有许多属性,允许精确放置内容。这种操作以前通常涉及各种复杂的 CSS 技巧。首先,让我们在容器中均匀分布盒子,使用基于行的间距。魔法属性是 justify-content,值为 space-between;图 4-10 (间距之间) 显示了结果:

.container {
  /* ... */
  flex-direction: row;
  justify-content: space-between;
}

有一个与 space-between 对称的 space-around,它通过在左右两侧添加相等的内边距来排列内容。图 4-10 (四周留白) 显示了结果:

.container {
  /* ... */
  justify-content: space-around;
}

通过组合 justify-contentalign-items 属性,我们可以实现 CSS 定位的圣杯,将内容垂直和水平居中。我们使用 flex 布局的 gap 属性在盒子之间增加了 20 像素的间距:

.container {
  /* ... */
    gap: 20px;
    justify-content: center;
    align-items: center;
}

图 4-10 (使用 gap 居中)显示我们的内容完全位于其容器的中间。

flex 布局的另一个很棒的特性是它是完全递归的。div 既可以 拥有 flex 属性,也可以 成为 flex 内容。这使得实现复杂的内容布局变得轻而易举。让我们来看一个嵌套的 flex 盒子的小演示,以便清晰地表达这一点。

我们首先使用一些 HTML 来构建盒子的嵌套树(包括主容器盒子)。我们给每个盒子和容器都分配一个 ID 和类:

  <div class="main-container">

    <div class="container" id="top-container">
      <div class="box" id="box1">box 1</div>
      <div class="box" id="box2">box 2</div>
    </div>

    <div class="container" id="middle-container">
      <div class="box" id="box3">box 3</div>
    </div>

    <div class="container" id="bottom-container">
      <div class="box" id="box4">box 4</div>
      <div class="box" id="box5">
        <div class="box" id="box6">box 6</div>
        <div class="box" id="box7">box 7</div>
      </div>
    </div>

  </div>

以下 CSS 给主容器设置了 800 像素的高度(默认情况下它会填充可用宽度),一个 flex 布局,并且一个 flex-direction 的列,使其堆叠其 flex 内容。

有三个要堆叠的容器,它们既灵活又为其内容提供了 flex 布局。这些盒子有一个红色边框,并且完全灵活(没有指定宽度或高度)。默认情况下,所有盒子的 flex 权重都为一。

中间容器有一个固定宽度的盒子(宽度为 66%),并使用 justify-content: center 将其居中。

底部容器的 flex 值为 2,使其高度是其兄弟元素的两倍。它有两个等重的盒子,其中一个(盒子 5)包含两个堆叠的盒子(flex-direction: column)。这种相当复杂的布局(参见图 4-11)只需非常少量的 CSS 即可实现,并且通过更改几个 flex 显示属性可以轻松适应:

.main-container {
  height: 800px;
  padding: 10px;
  border: 2px solid green;
  display: flex;
  flex-direction: column;
}

.container {
  flex: 1;
  display: flex;
}

.box {
  flex: 1;
  border: 2px solid red;
  padding: 10px;
  font-size: 30px;
}

#middle-container {
  justify-content: center;
}

#box3 {
  width: 66%;
  flex: initial;
}

#bottom-container {
  flex: 2;
}

#box5 {
  display: flex;
  flex-direction: column;
}

dpj2 0411

图 4-11. 嵌套的 flex-boxes

Flex-boxes 提供了一个非常强大的大小和定位上下文,适应 HTML 内容的容器大小,并且可以轻松地适应。如果您希望内容在列而不是行中显示,只需更改一个属性即可。对于更精确的定位和大小控制,可以使用CSS 网格布局,但我建议您首先集中精力学习 flex 布局——它目前代表了 CSS 学习投入的最佳回报。有关更多示例,请参阅CSS-Tricks 文章关于 flex-boxes 和这个便捷的速查表

填充占位符与内容

在 HTML 中定义了内容块并用 CSS 定位后,现代数据可视化使用 JavaScript 构建其交互式图表,菜单,表格等。在现代浏览器中,除了图像或多媒体标签之外,还有许多创建视觉内容的方式,主要包括:

  • 可伸缩矢量图形(SVG)使用特殊的 HTML 标签

  • 绘制到 2D canvas上下文

  • 绘制到 3D canvas WebGL 上下文,允许使用 OpenGL 命令的子集

  • 使用现代 CSS 创建动画,图形基元等

因为 SVG 是 D3 的首选语言,D3 是目前最大的 JavaScript 数据可视化库,许多您见过的酷炫网络数据可视化,例如纽约时报的那些,都是使用它构建的。总体来说,除非您预期在可视化中有大量(>1000)的移动元素或需要使用特定的基于canvas的库,否则 SVG 可能是更好的选择。

通过使用向量而不是像素来表达其基元,SVG 通常会生成更清晰的图形,对缩放操作响应平滑。它在处理文本方面也要好得多,这对许多可视化是一个关键考虑因素。SVG 的另一个关键优势是用户交互(例如,鼠标悬停或点击)是浏览器的本地功能,是标准 DOM 事件处理的一部分。^(12) 它的另一个优点是,由于图形组件建立在 DOM 上,您可以使用浏览器的开发工具检查和调整它们(参见“Chrome DevTools”)。这比试图在相对黑盒的canvas中找到错误要容易得多。

当您需要超越简单的图形基元(如圆圈和线条),例如在包含 PNG 和 JPG 图像时,canvas 绘图环境就发挥了自己的作用。相对于 SVG,canvas 通常具有更高的性能,因此任何具有大量移动元素的情况^(13) 最好使用 canvas 进行渲染。如果您想要真正雄心勃勃,或者超越二维图形,甚至可以通过使用特殊形式的 canvas 上下文,即基于 OpenGL 的 WebGL 上下文,释放现代图形卡的强大能力。但请记住,与 SVG 的简单用户交互(例如点击可视元素)通常必须从鼠标坐标手动派生,这增加了一个棘手的复杂层次。

在本书工具链的最后实现的诺贝尔奖数据可视化主要使用了 D3,因此 SVG 图形是本书的重点。熟悉 SVG 对于现代基于网络的数据可视化是基础,因此让我们探索一些基础知识。

可缩放矢量图形

所有的 SVG 创建都从 <svg> 根标签开始。所有的图形元素,如圆圈和线条,以及它们的组,都定义在 DOM 树的这个分支上。示例 4-3 展示了我们将在接下来的演示中使用的一个小型 SVG 上下文,一个带有 ID chart 的浅灰色矩形。我们还包括了从 d3js.org 载入的 D3 库以及项目文件夹中的 script.js JavaScript 文件。

示例 4-3. 一个基本的 SVG 上下文
<!DOCTYPE html>
<meta charset="utf-8">
<!-- A few CSS style rules -->
<style>
  svg#chart {
  background: lightgray;
  }
</style>

<svg id="chart" width="300" height="225">
</svg>

<!-- Third-party libraries and our JS script. -->
<script src="http://d3js.org/d3.v7.min.js"></script>
<script src="script.js"></script>

现在我们已经将我们的小型 SVG 画布放置好了,让我们开始做一些绘画。

元素

我们可以通过使用 <g> 元素在我们的 <svg> 元素中对形状进行分组。正如我们将在“使用群组”中看到的那样,包含在组中的形状可以一起操作,包括更改它们的位置、比例或不透明度。

圆圈

创建 SVG 可视化,从最简单的静态条形图到完整的交互式地理杰作,都涉及从一组相当小的图形基元(如线条、圆圈和非常强大的路径)中组合元素。每个元素都将有其自己的 DOM 标签,在其更改时更新。^(14) 例如,其 xy 属性将根据 <svg> 或组(<g>)上下文中的任何平移而更改。

让我们向我们的 <svg> 上下文中添加一个圆圈来演示:

<svg id="chart" width="300" height="225">
  <circle r="15" cx="100" cy="50"></circle>
</svg>

使用一点 CSS 为圆圈提供填充颜色:

#chart circle{ fill: lightblue }

这生成了图 4-12。请注意,y 坐标是从 <svg> '#chart' 容器顶部测量的,这是一种常见的图形约定。

dpj2 0412

图 4-12. 一个 SVG 圆圈

现在让我们看看如何向 SVG 元素应用样式。

应用 CSS 样式

在使用 CSS 样式规则填充浅蓝色的图 4-12 中的圆圈:

#chart circle{ fill: lightblue }

在现代浏览器中,您可以使用 CSS 设置大多数视觉 SVG 样式,包括 fillstrokestroke-widthopacity。所以,如果我们想要一条粗的、半透明的绿色线(带有 ID total),我们可以使用以下 CSS:

#chart line#total {
    stroke: green;
    stroke-width: 3px;
    opacity: 0.5;
}

您也可以将样式设置为标签的属性,尽管通常更喜欢使用 CSS:

<svg>
  <circle r="15" cx="100" cy="50" fill="lightblue"></circle>
</svg>
提示

哪些 SVG 特性可以通过 CSS 设置,哪些不能,这是一些混淆和许多陷阱的来源。SVG 规范区分元素的 属性 和属性,前者更可能出现在有效的 CSS 样式中。您可以使用 Chrome 的元素选项卡和自动完成来调查有效的 CSS 属性。另外,要做好一些准备,例如,SVG 文本的颜色由 fill 属性而不是 color 属性确定。

对于 fillstroke,您可以使用各种颜色约定:

  • 命名的 HTML 颜色,如 lightblue

  • 使用 HTML 十六进制代码(#RRGGBB);例如,白色是 #FFFFFF

  • RGB 值;例如,红色 = rgb(255, 0, 0)

  • RGBA 值,其中 A 是 alpha 通道(0–1);例如,半透明蓝色是 rgba(0, 0, 255, 0.5)

除了使用 RGBA 调整颜色的 alpha 通道外,您还可以使用 SVG 元素的 opacity 属性淡化 SVG 元素。在 D3 动画中经常使用不透明度。

默认情况下,描边宽度以像素为单位,但可以使用点。

线条、矩形和多边形

我们将向我们的图表添加一些更多的元素,以生成 图 4-13。

dpj2 0413

图 4-13. 向虚拟图表添加一些元素

首先,我们将使用 <line> 标签向我们的图表添加几条简单的轴线。线的位置由起始坐标(x1,y1)和结束坐标(x2,y2)定义:

<svg>
  <line x1="20" y1="20" x2="20" y2="130"></line>
  <line x1="20" y1="130" x2="280" y2="130"></line>
</svg>

我们还将使用 SVG 矩形在右上角添加一个虚拟图例框。矩形由相对于其父容器的 xy 坐标以及宽度和高度定义:

<svg>
  <rect x="240" y="5" width="55" height="30"></rect>
</svg>

您可以使用 <polygon> 标签创建不规则多边形,它接受一系列坐标对。让我们在图表的右下角制作一个三角形标记:

<svg>
  <polygon points="210,100, 230,100, 220,80"></polygon>
</svg>

我们将使用一些 CSS 样式化元素:

#chart circle {fill: lightblue}
#chart line {stroke: #555555; stroke-width: 2}
#chart rect {stroke: red; fill: white}
#chart polygon {fill: green}

现在我们已经有了一些图形原语,让我们看看如何向虚拟图表添加一些文本。

文本

SVG 相对于栅格化的 canvas 上下文的一个关键优势之一是它如何处理文本。基于矢量的文本往往看起来比其像素化的对应物更清晰,并且也受益于平滑缩放。您还可以调整描边和填充属性,就像任何 SVG 元素一样。

让我们向虚拟图表添加一些文本:一个标题和带标签的 y 轴(参见 图 4-14)。

dpj2 0414

图 4-14. 一些 SVG 文本

我们使用 xy 坐标放置文本。一个重要的属性是 text-anchor,它规定了文本相对于其 x 位置的放置位置。选项有 startmiddleendstart 是默认值。

我们可以使用text-anchor属性来使我们的图表标题居中。我们将x坐标设置为图表宽度的一半,然后将text-anchor设置为middle

<svg>
  <text id="title" text-anchor="middle" x="150" y="20">
    A Dummy Chart
  </text>
</svg>

与所有 SVG 基元一样,我们可以对文本应用缩放和旋转变换。为了标记我们的 y 轴,我们需要将文本旋转为垂直方向(示例 4-4)。按照惯例,旋转是顺时针的度数,所以我们希望逆时针旋转,-90 度旋转。默认情况下,旋转是围绕元素容器(<svg>或组<g>)的(0,0)点进行的。我们希望围绕其自身位置旋转文本,因此首先使用rotate函数的额外参数来转换旋转点。我们还希望首先将text-anchor设置为y 轴标签字符串的末端,以围绕其端点旋转。

示例 4-4. 旋转文本
<svg>
  <text x="20" y="20" transform="rotate(-90,20,20)"
      text-anchor="end" dy="0.71em">y axis label</text>
</svg>

在示例 4-4 中,我们利用文本的dy属性,与dx一起可以微调文本的位置。在这种情况下,我们希望将其降低,这样在逆时针旋转时它将位于 y 轴的右侧。

SVG 文本元素也可以使用 CSS 进行样式设置。在这里,我们将图表的font-family设置为sans-seriffont-size设置为16px,使用title ID 使其稍大:

#chart {
background: #eee;
font-family: sans-serif;
}
#chart text{ font-size: 16px }
#chart text#title{ font-size: 18px }

注意,text元素继承自图表的 CSS 中的font-familyfont-size;您不必为text元素指定。

路径

路径是最复杂和强大的 SVG 元素,可以创建多行、多曲线组件路径,可以闭合和填充,从而创建几乎任何形状。一个简单的例子是在我们的虚拟图表中添加一条小图表线,生成图 4-15。

dpj2 0415

图 4-15. 来自图表轴的红线路径

图 4-15 中的红色路径是由以下 SVG 生成的:

<svg>
  <path d="M20 130L60 70L110 100L160 45"></path>
</svg>

pathd属性指定了制作红线所需的一系列操作。让我们详细分解一下:

  • “M20 130”: 移动到坐标 (20, 130)

  • “L60 70”: 画一条线到 (60, 70)

  • “L110 100”: 画一条线到 (110, 100)

  • “L160 45”: 画一条线到 (160, 45)

您可以将d想象成一组指示笔移动到点的指令,其中M从画布上抬起笔。

需要一些 CSS 样式。请注意,fill设置为none;否则,为了创建填充区域,路径将闭合,并从其结束点到起始点绘制一条线,并用默认颜色黑色填充任何封闭区域:

#chart path {stroke: red; fill: none}

除了moveto 'M'lineto 'L',路径还有许多其他命令来绘制弧线、贝塞尔曲线等。SVG 弧线和曲线在数据可视化工作中经常使用,D3 的许多库都在使用它们。^(15) 图 4-16 展示了以下代码创建的一些 SVG 椭圆弧:

<svg id="chart" width="300" height="150">
  <path d="M40 40
           A30 40 ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png) 0 0 1 ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png) 80 80
           A50 50  0 0 1  160  80
           A30 30  0 0 1  190  80
">
</svg>

1

移动到位置 (40, 40) 后,绘制一个 x 半径为 30,y 半径为 40,终点为 (80, 80) 的椭圆弧。

2

第一个标志 (0) 设置了 x 轴的旋转,在本例中为传统的零度。参见 Mozilla 开发者网站 进行视觉演示。最后两个标志 (0, 1) 是 large-arc-flag,指定要使用椭圆的哪个弧,以及 sweep-flag,指定由起点和终点定义的两个可能椭圆中的哪一个。

dpj2 0416

图 4-16. 一些 SVG 椭圆弧

椭圆弧中使用的关键标志(large-arc-flagsweep-flag),像大多数几何事物一样,最好是演示而不是描述。图 4-17 展示了在相同的相对起始点和端点情况下改变标志的效果,如下所示:

<svg id="chart" width="300" height="150">
  <path d="M40 80
 A30 40  0 0 1  80 80
 A30 40  0 0 0  120  80
 A30 40  0 1 0  160  80
 A30 40  0 1 1  200  80
">
</svg>

dpj2 0417

图 4-17. 改变椭圆弧标志

除了直线和弧线外,path 元素还提供了多种贝塞尔曲线,包括二次、三次以及二者的复合曲线。通过一些工作,这些曲线可以创建任何你想要的线路路径。在 SitePoint 上有一个很好的运行过程,配有良好的插图。

想要了解 path 元素及其参数的完整列表,请访问 World Wide Web Consortium (W3C) 源。另外,可以参考 Jakob Jenkov 的介绍 进行详细了解。

缩放和旋转

作为其向量性质的体现,所有的 SVG 元素都可以通过几何操作进行变换。最常用的是 rotatetranslatescale,但你也可以使用 skewXskewY 进行倾斜,或者使用功能强大的多用途 matrix 变换。

让我们演示最流行的变换,使用一组相同的矩形。通过如下方式实现 图 4-18 中的变换矩形:

<svg id="chart" width="300" height="150">
  <rect width="20" height="40" transform="translate(60, 55)"
        fill="blue"/>
  <rect width="20" height="40" transform="translate(120, 55),
 rotate(45)" fill="blue"/>
  <rect width="20" height="40" transform="translate(180, 55),
 scale(0.5)" fill="blue"/>
  <rect width="20" height="40" transform="translate(240, 55),
 rotate(45),scale(0.5)" fill="blue"/>
</svg>

dpj2 0418

图 4-18. 一些 SVG 变换:rotate(45),scale(0.5),scale(0.5),然后 rotate(45)
注意

变换的应用顺序很重要。顺时针旋转 45 度,然后沿 x 轴平移将使元素向东南移动,而反向操作则将其向左移动,然后旋转。

使用组

在构建可视化时,通常将视觉元素分组是很有帮助的。一些特定的用途包括:

  • 当需要局部坐标方案时(例如,如果你有一个图标的文本标签,想要指定其相对于图标而不是整个 <svg> 画布的位置)。

  • 如果要对部分视觉元素应用缩放和/或旋转变换。

SVG 使用 <g> 标签来进行分组,你可以将其视为 <svg> 画布中的小型画布。组可以包含组,从而允许非常灵活的几何映射。^(16)

示例 4-5 将形状分组在画布的中心,生成图 4-19。注意circlerectpath元素的位置是相对于被转换的组而言的。

示例 4-5. 对 SVG 形状进行分组
<svg id="chart" width="300" height="150">
  <g id="shapes" transform="translate(150,75)">
    <circle cx="50" cy="0" r="25" fill="red" />
    <rect x="30" y="10" width="40" height="20" fill="blue" />
    <path d="M-20 -10L50 -10L10 60Z" fill="green" />
    <circle r="10" fill="yellow">
  </g>
</svg>

dpj2 0419

图 4-19. 使用 SVG <g> 标签对形状进行分组

如果我们现在对组应用一个变换,组内的所有形状都将受到影响。图 4-20 展示了通过将图 4-19 按 0.75 倍缩放并旋转 90 度来实现的结果,我们通过调整 transform 属性来实现这一点,如下所示:

<svg id="chart" width="300" height="150">
  <g id="shapes",
     transform = "translate(150,75),scale(0.5),rotate(90)">
     ...
</svg>

dpj2 0420

图 4-20. 对 SVG 组进行变换

层叠和透明度

在 DOM 树中添加 SVG 元素的顺序很重要,后面的元素会覆盖前面的元素。例如,在图 4-19 中,三角形路径遮盖了红色圆形和蓝色矩形,同时被黄色圆形遮盖。

控制 DOM 的排序是 JavaScript 数据可视化的重要部分(例如,D3 的 insert 方法允许您在现有元素之前放置一个 SVG 元素)。

可以使用 rgba(R,G,B,A) 颜色的 alpha 通道或更方便的 opacity 属性来控制元素的透明度。两者都可以使用 CSS 来设置。对于重叠的元素,透明度是累积的,正如在图 4-21 中的颜色三角形所示,该三角形是通过以下 SVG 生成的:

<style>
  #chart circle { opacity: 0.33 }
</style>

<svg id="chart" width="300" height="150">
  <g transform="translate(150, 75)">
    <circle cx="0" cy="-20" r="30" fill="red"/>
    <circle cx="17.3" cy="10" r="30" fill="green"/>
    <circle cx="-17.3" cy="10" r="30" fill="blue"/>
  </g>
</svg>

这里展示的 SVG 元素是在 HTML 中手工编码的,但在数据可视化工作中,它们几乎总是以编程方式添加的。因此,基本的 D3 工作流程是向可视化中添加 SVG 元素,使用数据文件来指定它们的属性和属性。

dpj2 0421

图 4-21. 使用 SVG 调整不透明度

JavaScripted SVG

SVG 图形由 DOM 标签描述的事实相比 <canvas> 上的黑盒有许多优势。例如,它允许非程序员创建或调整图形,并且有助于调试。

在 web 数据可视化中,几乎所有的 SVG 元素都是通过 JavaScript 创建的,例如使用 D3 这样的库。您可以使用浏览器的元素选项卡检查这些脚本的结果(参见“Chrome DevTools”),这是精细调整和调试工作的好方法(例如解决令人烦恼的视觉故障)。

为了展示即将发生的事情,让我们使用 D3 在 SVG 画布上散布一些红色圆圈。画布和圆圈的尺寸包含在发送给 chartCircles 函数的 data 对象中。

我们使用一个小的 HTML 占位符来表示<svg>元素:

<!DOCTYPE html>
<meta charset="utf-8">

<style>
  #chart { background: lightgray; }
  #chart circle {fill: red}
</style>

<body>
  <svg id="chart"></svg>

  <script src="http://d3js.org/d3.v7.min.js"></script>
  <script src="script.js"></script>
</body>

放置了我们的占位符 SVG chart 元素后,script.js 文件中的少量 D3 代码被用来将一些数据转换为散点圆圈(参见图 4-22):

// script.js 
var chartCircles = function(data) {

    var chart = d3.select('#chart');
    // Set the chart height and width from data
    chart.attr('height', data.height).attr('width', data.width);
    // Create some circles using the data
    chart.selectAll('circle').data(data.circles)
        .enter()
        .append('circle')
        .attr('cx', function(d) { return d.x })
        .attr('cy', d => d.y) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
        .attr('r', d => d.r);
};

var data = {
    width: 300, height: 150,
    circles: [
        {'x': 50, 'y': 30, 'r': 20},
        {'x': 70, 'y': 80, 'r': 10},
        {'x': 160, 'y': 60, 'r': 10},
        {'x': 200, 'y': 100, 'r': 5},
    ]
};

chartCircles(data);

1

这是现代箭头匿名函数的简写形式,等同于上一行的长形式。D3 大量使用这种方法来访问绑定数据对象的属性,因此这种新的语法是一大进步。

dpj2 0422

图 4-22. D3 生成的圆圈

我们将在第十七章中详细了解 D3 的奥秘。现在,让我们总结一下本章学到的内容。

总结

本章为初学数据可视化者提供了一组基本的现代 Web 开发技能。它展示了 Web 页面的各种元素(HTML、CSS 样式表、JavaScript 和媒体文件)是如何通过 HTTP 传送的,并且在被浏览器接收后,如何组合成用户看到的网页。我们看到了如何描述内容块,使用 HTML 标签如divp,然后使用 CSS 进行样式化和定位。我们还介绍了 Chrome 的 Elements 和 Sources 标签,这是关键的浏览器开发工具。最后,我们简要介绍了 SVG,这是大多数现代 Web 数据可视化表达的语言。这些技能将在我们的工具链达到其 D3 可视化时进行扩展,并且将在上下文中引入新的技能。

^(1) 目前有一些备受关注的替代方案,与 Python Web 服务器(如 Django 和Flask)兼容良好,例如Alpine.js 和 htmx

^(2) 我承受了伤痕,这样你就不必了。

^(3) 你可以使用style属性在 HTML 标签中编写样式,但这通常是一种不良实践。最好使用在 CSS 中定义的类和 ID。

^(4) 正如迈克·博斯托克所演示的,向保罗·爱尔兰致敬。

^(5) OpenGL(Open Graphics Language)及其网络对应物 WebGL 是跨平台的 API,用于渲染 2D 和 3D 矢量图形(详情请参阅Wikipedia 页面)。

^(6) 这与以编程方式设置样式不同,后者是一种非常强大的技术,允许样式根据用户交互进行调整。

^(7) 这通常被认为是一种不良实践,通常是 CSS 结构混乱的指示。请极度谨慎使用,因为它可能会让代码开发人员的生活变得非常困难。

^(8) 这些在道格拉斯·克罗克福德著名简短的JavaScript: The Good Parts(O'Reilly)中简要讨论。

^(9) 当尝试使可缩放矢量图形(SVG)工作时,能够玩弄属性尤其有用。

^(10) 记录日志是跟踪应用程序数据流的好方法。我建议您在这里采用一致的方法。

^(11) 这里有一个展示问题多样解决方案的讨论串,没有一个可以称得上优雅。

^(12) 使用canvas图形上下文,通常需要自己设计事件处理。

^(13) 这个数字随时间和所用的浏览器而变化,但作为一个粗略的经验法则,SVG 在低千个元素时通常开始吃力。

^(14) 您应该能够使用浏览器的开发工具实时查看标签属性的更新。

^(15) 迈克·博斯托克的弦图是一个很好的例子,使用了 D3 的chord函数。

^(16) 例如,一个身体组可以包含一个手臂组,手臂组可以包含一个手组,手组可以包含手指元素。

第二部分:获取数据

本书的这一部分开始了我们沿着数据可视化工具链的旅程(见 图 II-1),首先介绍了几章如何获取数据,如果数据没有提供给你的话。

在第五章中,我们学习如何从网络获取数据,使用 Python 的 Requests 库来获取基于 web 的文件和消费 RESTful API。我们还看到了如何使用一些包装更复杂的 web API 的 Python 库,包括 Twitter(使用 Python 的 Tweepy)和 Google Docs。本章以 Beautiful Soup 库进行轻量级网络抓取的示例结束。

在第六章中,我们使用了 Scrapy,Python 的工业级网络爬虫,获取了诺贝尔奖数据集,这将用于我们的网络可视化。有了这个肮脏的数据集,我们准备进入本书的下一部分,第 III 部分。

dpj2 p223

图 II-1. 我们的数据可视化工具链:获取数据
提示

你可以在书籍的 GitHub 仓库找到本书这部分的代码。

第五章:使用 Python 从网络获取数据

数据可视化工具箱中的一个基本部分是以尽可能干净的形式获取正确的数据集。有时你会被提供一个漂亮干净的数据集来分析,但通常你需要找到数据并/或清理提供的数据。

而如今更多的时候,获取数据意味着从网络上获取数据。你可以通过各种方式实现这一点,Python 提供了一些很棒的库来简化数据的获取过程。

从网络获取数据的主要方法包括:

  • 从 HTTP 获取一个识别的数据格式的原始数据文件(例如 JSON 或 CSV)。

  • 使用专用 API 获取数据。

  • 通过 HTTP 获取网页并在本地解析以获取所需数据。

本章将依次处理这些方法,但首先让我们熟悉一下目前最好的 Python HTTP 库:Requests。

使用 Requests 库获取网络数据

正如我们在第四章中看到的,用于构建网页的文件通过超文本传输协议(HTTP)进行通信,最初由Tim Berners-Lee开发。获取网页内容以解析数据涉及发出 HTTP 请求。

处理 HTTP 请求是任何通用语言的重要部分,但是以前在 Python 中获取网页是一件相当讨厌的事情。古老的 urllib2 库几乎没有用户友好的 API,其使用非常笨拙。Requests,由 Kenneth Reitz 提供,改变了这一状况,使得 HTTP 请求变得相对轻松,并迅速确立了作为首选 Python HTTP 库的地位。

Requests 不是 Python 标准库的一部分^(1),但是是Anaconda 包的一部分(参见第一章)。如果你没有使用 Anaconda,以下pip命令应该可以胜任:

$ pip install requests
Downloading/unpacking requests
...
Cleaning up...

如果你使用的是 Python 2.7.9 之前的版本(我强烈建议尽可能使用 Python 3+),使用 Requests 可能会生成一些安全套接字层(SSL)警告。升级到更新的 SSL 库应该可以解决这个问题:^(2)

$ pip install --upgrade ndg-httpsclient

现在你已经安装了 Requests,可以执行本章开头提到的第一个任务,从网络上获取一些原始数据文件。

使用 Requests 获取数据文件

使用 Python 解释器会话是测试 Requests 的一个好方法,所以找一个友好的本地命令行,启动 IPython,并导入requests

$ ipython
Python 3.8.9 (default, Apr  3 2021, 01:02:10)
...

In [1]: import requests

为了演示,让我们使用该库下载一个维基百科页面。我们使用 Requests 库的get方法获取页面,并按照惯例将结果分配给一个response对象:

response = requests.get(\
"https://en.wikipedia.org/wiki/Nobel_Prize")

让我们使用 Python 的dir方法来获取response对象的属性列表:

dir(response)
Out:
...
 ['content',
 'cookies',
 'elapsed',
 'encoding',
 'headers',
 ...
 'iter_content',
 'iter_lines',
 'json',
 'links',
 ...
 'status_code',
 'text',
 'url']

大多数这些属性是不言自明的,一起提供了大量关于生成的 HTTP 响应的信息。通常情况下,你将只使用这些属性的一个小子集。首先,让我们检查响应的状态:

response.status_code
Out: 200

所有优秀的最小化网络开发者都知道,200 是HTTP 状态码表示 OK,指示成功的事务。除了 200 之外,最常见的代码是:

401(未经授权)

尝试未经授权的访问

400(错误请求)

尝试错误地访问 Web 服务器

403(禁止)

类似于 401,但没有登录机会可用

404(未找到)

尝试访问一个不存在的网页

500(内部服务器错误)

一个通用的、万能的错误

因此,例如,如果我们在请求中拼写错误,请求查看SNoble_Prize页面,我们将收到 404(未找到)错误:

not_found_response = requests.get(\
"http://en.wikipedia.org/wiki/SNobel_Prize")
not_found_response.status_code
Out: 404

通过我们的 200 OK 响应,从正确拼写的请求中,让我们看一下返回的一些信息。可以通过headers属性快速查看概述:

response.headers
Out: {
  'date': 'Sat, 23 Oct 2021 23:58:49 GMT',
  'server': 'mw1435.eqiad.wmnet',
  'content-encoding': 'gzip', ...
  'last-modified': 'Sat, 23 Oct 2021 17:14:09 GMT', ...
  'content-type': 'text/html; charset=UTF-8'...
  'content-length': '88959'
  }

这显示,除其他外,返回的页面是经过 gzip 编码的,大小为 87 KB,content-typetext/html,使用 Unicode UTF-8 编码。

由于我们知道已经返回了文本,我们可以使用响应的text属性来查看它是什么:

response.text
#Out: u'<!DOCTYPE html>\n<html lang="en"
#dir="ltr" class="client-nojs">\n<head>\n<meta charset="UTF-8"
#/>\n<title>Nobel Prize - Wikipedia, the free
#encyclopedia</title>\n<script>document.documentElement... =

这表明我们确实有我们的维基百科 HTML 页面,带有一些内联 JavaScript。正如我们将在“抓取数据”中看到的那样,为了理解这个内容,我们需要一个解析器来读取 HTML 并提供内容块。

现在我们已经从网络上抓取了一个原始页面,让我们看看如何使用 Requests 来消耗网络数据 API。

使用 Python 从 Web API 中获取数据

如果你需要的数据文件不在网络上,很可能有一个应用程序编程接口(API)提供你所需的数据。使用这个 API 将涉及向适当的服务器发出请求,以获取你在请求中指定的固定格式或你指定的格式中的数据。

Web API 的最流行的数据格式是 JSON 和 XML,尽管存在许多奇特的格式。对于 JavaScript 数据可视化器的目的,JavaScript 对象表示法(JSON)显然是首选的(见“数据”)。幸运的是,它也开始占主导地位。

创建 Web API 有不同的方法,并且在几年内有三种主要类型的 API 在网络中存在一场小规模的架构之争:

REST

表示表现状态转移,使用 HTTP 动词(GET,POST 等)和统一资源标识符(URI;例如,/user/kyran)来访问,创建和调整数据的组合。

XML-RPC

使用 XML 编码和 HTTP 传输的远程过程调用(RPC)协议。

SOAP

表示简单对象访问协议,使用 XML 和 HTTP。

这场战斗似乎正在向RESTful APIs的胜利方向解决,这是一件非常好的事情。除了 RESTful API 更加优雅、更易于使用和实现(参见第十三章),在这里进行一些标准化使得您更有可能认识并快速适应新的 API。理想情况下,您将能够重用现有代码。作为一个数据可视化者,有一个新的参与者出现,名为GraphQL,自称为更好的 REST,但您更有可能使用传统的 RESTful API。

大多数远程数据的访问和操作可以通过 CRUD(创建、检索、更新、删除)这个缩写来概括,最初用来描述在关系数据库中实现的所有主要功能。HTTP 提供了 CRUD 的对应操作,包括 POST、GET、PUT 和 DELETE 动词,而 REST 抽象则基于这些动词的使用,作用于统一资源标识符(URI)

讨论什么是一个适当的 RESTful 接口,可以变得非常复杂,但基本上,URI(例如,*https://example.com/api/items/2*)应包含执行 CRUD 操作所需的所有信息。具体操作(例如 GET 或 DELETE)由 HTTP 动词指定。这不包括像 SOAP 这样的体系结构,它将有状态信息放在请求头的元数据中。将 URI 想象成数据的虚拟地址,CRUD 是您可以对其执行的所有操作。

作为渴望获得一些有趣数据集的数据可视化者,我们在这里是狂热的消费者,因此我们选择的 HTTP 动词是 GET,接下来的示例将专注于使用各种知名的 Web API 获取数据。希望能找出一些模式。

尽管无状态 URI 和使用 CRUD 动词的两个约束是 RESTful API 形状上的一个不错的约束条件,但这里仍然有很多主题的变体。

使用 Requests 消费 RESTful Web API

Requests 围绕主要的 HTTP 请求动词有相当多的功能和特性。详细内容请参阅Requests 快速入门。为了获取数据,您几乎完全会使用 GET 和 POST 动词,其中 GET 是远远最常用的动词。POST 允许您模拟 Web 表单,包括登录详细信息、字段值等在请求中。对于那些需要使用 POST 轻松驱动 Web 表单的情况,例如具有大量选项选择器的情况,Requests 使得自动化变得容易。GET 几乎涵盖了所有其他内容,包括无处不在的 RESTful API,这些 API 提供了越来越多的网上可用的形式良好的数据。

让我们来看一个更复杂的请求使用 Requests,获取带有参数的 URL。经济合作与发展组织(OECD)提供了一些有用的数据集。这些数据集主要提供 OECD 成员国的经济措施和统计数据,这些数据可以成为许多有趣可视化的基础。OECD 提供了一些自己的可视化工具,如允许您与 OECD 中的其他国家进行比较的工具。

OECD 网络 API 在这个文档中有详细描述,查询由数据集名称(dsname)和一些用点分隔的维度构成,每个维度可以是多个用+分隔的值。URL 也可以采用标准的 HTTP 参数,以?开头,用&分隔:

<root_url>/<dsname>/<dim 1>.<dim 2>...<dim n>
/all?param1=foo&param2=baa..
<dim 1> = 'AUS'+'AUT'+'BEL'...

以下是有效的 URL:

http://stats.oecd.org/sdmx-json/data/QNA   ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
    /AUS+AUT.GDP+B1_GE.CUR+VOBARSA.Q       ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
    /all?startTime=2009-Q2&endTime=2011-Q4 ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)

1

指定季度国民账户(QNA)数据集。

2

四个维度,按地点、主题、措施和频率。

3

数据从 2009 年第二季度到 2011 年第四季度。

让我们构建一个小的 Python 函数来查询 OECD 的 API(示例 5-1)。

示例 5-1. 构建 OECD API 的 URL
OECD_ROOT_URL = 'http://stats.oecd.org/sdmx-json/data'

def make_OECD_request(dsname, dimensions, params=None, \ root_dir=OECD_ROOT_URL):
    """ Make a URL for the OECD API and return a response """

    if not params: ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
        params = {}

    dim_args = ['+'.join(d) for d in dimensions] ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
    dim_str = '.'.join(dim_args)

    url = root_dir + '/' + dsname + '/' + dim_str + '/all'
    print('Requesting URL: ' + url)
    return requests.get(url, params=params) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)

1

对于 Python 函数默认值,不应使用可变值,如{}。参见这个 Python 指南以了解此问题的解释。

2

我们首先使用 Python 的列表推导和join方法创建一个维度列表,成员用加号连接(例如,[USA+AUS, …​ ])。然后再次使用joindim_str的成员用句点连接。

3

注意requestsget可以将参数字典作为其第二个参数,用于构建 URL 查询字符串。

我们可以像这样使用这个函数,从 2009 年到 2010 年获取美国和澳大利亚的经济数据:

response = make_OECD_request('QNA',
    (('USA', 'AUS'),('GDP', 'B1_GE'),('CUR', 'VOBARSA'), ('Q')),
    {'startTime':'2009-Q1', 'endTime':'2010-Q1'})
Requesting URL: http://stats.oecd.org/sdmx-json/data/QNA/
    USA+AUS.GDP+B1_GE.CUR+VOBARSA.Q/all

现在,查看数据,只需检查响应是否正常,并查看字典键:

if response.status_code == 200:
   json = response.json()
   json.keys()
Out: [u'header', u'dataSets', u'structure']

结果 JSON 数据以SDMX 格式提供,旨在促进统计数据的通信。这不是最直观的格式,但往往是数据集结构不理想的情况。好消息是,Python 非常适合整理数据。对于 Python 的pandas 库(参见第八章),有pandaSDMX,目前可以处理基于 XML 的格式。

OECD API 基本上是 RESTful 的,所有查询都包含在 URL 中,HTTP 动词 GET 指定了获取操作。如果没有专门的 Python 库可用于使用该 API(例如 Twitter 的 Tweepy),那么您可能最终会编写类似于示例 5-1 的东西。Requests 是一个非常友好且设计良好的库,几乎可以处理使用 Web API 所需的所有操作。

获取诺贝尔数据可视化的国家数据

在我们正在使用的工具链中,有一些国家统计数据将对我们构建的诺贝尔奖可视化非常有用。人口规模、三字母国际代码(例如 GDR、USA)和地理中心在可视化国际奖项及其分布时可能非常有用。REST countries是一个非常方便的 RESTful 网络资源,提供各种国际统计数据。让我们用它来获取一些数据。

对 REST 国家的请求采用以下形式:

https://restcountries.com/v3.1/<field>/<name>?<params>

就像对 OECD API(参见示例 5-1)一样,我们可以创建一个简单的调用函数,以便轻松访问 API 的数据,如下所示:

REST_EU_ROOT_URL = "https://restcountries.com/v3.1"

def REST_country_request(field='all', name=None, params=None):

    headers={'User-Agent': 'Mozilla/5.0'} ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)

    if not params:
        params = {}

    if field == 'all':
         response = requests.get(REST_EU_ROOT_URL + '/all')
         return response.json()

    url = '%s/%s/%s'%(REST_EU_ROOT_URL, field, name)
    print('Requesting URL: ' + url)
    response = requests.get(url, params=params, headers=headers)

    if not response.status_code == 200: ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
        raise Exception('Request failed with status code ' \
        + str(response.status_code))

     return response.json() # JSON encoded data

1

通常最好在请求头中指定一个有效的User-Agent。否则,一些网站可能会拒绝请求。

2

在返回响应之前,请确保它具有 OK(200)HTTP 代码;否则,请提供一个带有有用信息的异常。

现在有了REST_country_request函数,让我们获取所有使用美元作为货币的国家列表:

response = REST_country_request('currency', 'usd')
response
Out:
[{u'alpha2Code': u'AS',
  u'alpha3Code': u'ASM',
  u'altSpellings': [u'AS',
  ...
  u'capital': u'Pago Pago',
  u'currencies': [u'USD'],
  u'demonym': u'American Samoan',
  ...
  u'latlng': [12.15, -68.266667],
  u'name': u'Bonaire',
  ...
  u'name': u'British Indian Ocean Territory',
  ...
  u'name': u'United States Minor Outlying Islands',
  ... ]}]

在 REST 国家的完整数据集相当小,因此为了方便起见,我们将其存储为 JSON 文件。我们将在后续章节中使用它,进行探索性和展示性的数据可视化:

import json

country_data = REST_country_request() # all world data

with open('data/world_country_data.json', 'w') as json_file:
    json.dump(country_data, json_file)

现在我们已经编写了几个自己的 API 消费者,让我们来看看一些专门的库,它们包装了一些更大的 Web API,以便于使用。

使用库访问 Web API

Requests 能够处理几乎所有的 Web API,但随着 API 开始添加认证和数据结构变得更复杂,一个好的包装库可以节省大量麻烦,减少繁琐的账务工作。在本节中,我将介绍一些较受欢迎的包装库,帮助你了解工作流程和一些有用的起始点。

使用 Google 电子表格

如今,拥有存储在云端的实时数据集变得越来越普遍。例如,你可能需要可视化作为一个群组共享数据池的 Google 电子表格的各个方面。我更喜欢将这些数据从 Googleplex 导入 pandas 来开始探索(见第十一章),但一个好的库可以让你在需要时直接访问和调整数据原地,协商网络流量。

gspread 是访问 Google 电子表格最知名的 Python 库,使用起来相对轻松。

你需要OAuth 2.0凭证来使用 API。^(3) 可以在Google Developers 网站上找到最新的指南,按照这些说明应该可以获取包含你私钥的 JSON 文件。

你需要安装gspread和最新的google-auth客户端库。以下是使用pip安装的方法:

$ pip install gspread
$ pip install --upgrade google-auth

根据你的系统,你可能还需要 pyOpenSSL:

$ pip install PyOpenSSL

阅读文档获取更多详情和故障排除。

注意

Google 的 API 假定你尝试访问的电子表格是由你的 API 账户拥有或共享的,而不是你的个人账户。分享电子表格的电子邮件地址可以在你的Google 开发者控制台以及在使用 API 所需的 JSON 凭证键中找到,它应该看起来像account-1@My Project…​iam.gserviceaccount.com

安装了这些库之后,你应该能够只用几行代码访问任何你的电子表格。我正在使用Microbe-scope spreadsheet。示例 5-2 展示了如何加载电子表格。

示例 5-2. 打开一个 Google 电子表格
import gspread

gc = gspread.service_account(\
                   filename='data/google_credentials.json') ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)

ss = gc.open("Microbe-scope") ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)

1

JSON 凭证文件是由 Google 服务提供的,通常格式为My Project-b8ab5e38fd68.json

2

在这里,我们通过名称打开电子表格。也可以使用open_by_urlopen_by_id。详细信息请参阅gspread文档

现在我们已经有了我们的电子表格,可以看到它包含的工作表:

ss.worksheets()
Out:
[<Worksheet 'bugs' id:0>,
 <Worksheet 'outrageous facts' id:430583748>,
 <Worksheet 'physicians per 1,000' id:1268911119>,
 <Worksheet 'amends' id:1001992659>]

ws = ss.worksheet('bugs')

选择电子表格中的bugs工作表后,gspread允许您访问和更改列、行和单元格的值(假设表格不是只读的)。因此,我们可以使用col_values命令获取第二列中的值:

ws.col_values(1)
Out: [None,
 'grey = not plotted',
 'Anthrax (untreated)',
 'Bird Flu (H5N1)',
 'Bubonic Plague (untreated)',
 'C.Difficile',
 'Campylobacter',
 'Chicken Pox',
 'Cholera',...]
提示

如果在使用gspread访问 Google 电子表格时出现BadStatusLine错误,则可能是会话已过期。重新打开电子表格应该可以使事情重新运行。这个未解决的gspread问题提供了更多信息。

虽然您可以使用gspread的 API 直接绘制图表,例如使用 Matplotlib 等绘图库,但我更喜欢将整个表格发送到 pandas,Python 的强大程序化电子表格。这可以通过gspreadget_all_records轻松实现,它返回一个项目字典列表。这个列表可以直接用于初始化一个 pandas DataFrame(参见“DataFrame”):

df = pd.DataFrame(ws.get_all_records(expected_headers=[]))
df.info()
Out:
<class 'pandas.core.frame.DataFrame'>
Int64Index: 41 entries, 0 to 40
Data columns (total 23 columns):
                                          41 non-null object
average basic reproductive rate           41 non-null object
case fatality rate                        41 non-null object
infectious dose                           41 non-null object
...
upper R0                                  41 non-null object
viral load in acute stage                 41 non-null object
yearly fatalities                         41 non-null object
dtypes: object(23)
memory usage: 7.5+ KB

在第十一章中,我们将看到如何交互式地探索 DataFrame 的数据。

使用 Tweepy 的 Twitter API

社交媒体的出现产生了大量数据,并引起了对可视化其中包含的社交网络、流行标签和媒体风暴的兴趣。Twitter 的广播网络可能是最丰富的数据可视化来源,其 API 提供按用户、标签、日期等过滤的推文^(4)。

Python 的 Tweepy 是一个易于使用的 Twitter 库,提供了许多有用的功能,例如用于流式传输实时 Twitter 更新的StreamListener类。要开始使用它,您需要一个 Twitter 访问令牌,可以通过按照Twitter 文档中的说明创建您的 Twitter 应用程序来获取。一旦创建了此应用程序,您可以通过单击链接在您的 Twitter 应用程序页面上获取您应用程序的密钥和访问令牌。

Tweepy 通常需要这里显示的四个授权元素:

# The user credential variables to access Twitter API
access_token = "2677230157-Ze3bWuBAw4kwoj4via2dEntU86...TD7z"
access_token_secret = "DxwKAvVzMFLq7WnQGnty49jgJ39Acu...paR8ZH"
consumer_key = "pIorGFGQHShuYQtIxzYWk1jMD"
consumer_secret = "yLc4Hw82G0Zn4vTi4q8pSBcNyHkn35BfIe...oVa4P7R"

有了这些定义,访问推文变得非常简单。在这里,我们使用我们的令牌和密钥创建一个 OAuth auth对象,并用它来启动 API 会话。然后,我们可以从我们的时间线获取最新的推文:

In [0]: import tweepy

        auth = tweepy.OAuthHandler(consumer_key,\
                                   consumer_secret)
        auth.set_access_token(access_token, access_token_secret)

        api = tweepy.API(auth)

        public_tweets = api.home_timeline()
        for tweet in public_tweets:
            print(tweet.text)
RT @Glinner: Read these tweets https://t.co/QqzJPsDxUD
Volodymyr Bilyachat https://t.co/VIyOHlje6b +1 bmeyer
#javascript
RT @bbcworldservice: If scientists edit genes to
make people healthier does it change what it means to be
human? https://t.co/Vciuyu6BCx h…
RT @ForrestTheWoods:
Launching something pretty cool tomorrow. I'm excited. Keep
...

Tweepy 的API类提供了许多方便的方法,您可以在Tweepy 文档中查看。一个常见的可视化方法是使用网络图来展示 Twitter 子群体中朋友和关注者的模式。Tweepy 方法followers_ids(获取所有关注用户)和friends_ids(获取所有被关注用户)可用于构建这样一个网络:

my_follower_ids = api.get_follower_ids() ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)

followers_tree = {'followers': []}
for id in my_follower_ids:
    # get the followers of your followers
    try:
         follower_ids = api.get_follower_ids(user_id=id) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
    except tweepy.errors.Unauthorized:
         print("Unauthorized to access user %d's followers"\
               %(id))

    followers_tree['followers'].append(\
        {'id': id, 'follower_ids': follower_ids})

1

获取您关注者的 ID 列表(例如,[1191701545, 1554134420, …])。

2

follower_ids的第一个参数可以是用户 ID 或屏幕名称。

请注意,如果你尝试为拥有超过一百个追随者的任何人构建网络,可能会遇到速率限制错误(详见这个 Stack Overflow 帖子的解释)。为了克服这一问题,你需要实施一些基本的速率限制,以将你的请求计数减少到每 15 分钟 180 次。或者,你可以支付 Twitter 以获取高级账户。

通过映射追随者的追随者,你可以创建一个连接网络,可能会揭示与特定个体或主题相关的群组和子群体中的有趣内容。关于这种 Twitter 分析的一个很好的例子可以在Gabe Sawhney 的博客中找到。

Tweepy 中最酷的功能之一是其StreamListener类,它使得在实时收集和处理经过筛选的推文变得很容易。Twitter 流的实时更新已经被许多令人难忘的可视化所使用(请参见FlowingDataDensityDesign的这些例子,以获得一些灵感)。让我们建立一个小流来记录提到 Python、JavaScript 和数据可视化的推文。我们只会将结果打印到屏幕上(在on_data中),但通常你会将它们缓存到文件或数据库中(或者两者都用 SQLite):

import json

class MyStream(tweepy.Stream):
    """ Customized tweet stream """

    def on_data(self, tweet):
        """Do something with the tweet data..."""
        print(tweet)

    def on_error(self, status):
        return True # keep stream open

stream = MyStream(consumer_key, consumer_secret,\
                     access_token, access_token_secret)
# Start the stream with track list of keywords
stream.filter(track=['python', 'javascript', 'dataviz'])

现在我们已经品尝了你在寻找有趣数据过程中可能遇到的 API 类型,让我们来看看你将使用的主要技术,如果像通常情况下一样,没有人以整洁、用户友好的形式提供你想要的数据:用 Python 抓取数据。

抓取数据

抓取是用来获取那些并非被设计为以程序方式消费的数据的实践的首要隐喻。这是一个相当好的隐喻,因为抓取通常涉及在移除过多或过少的数据之间取得平衡。创建能够尽可能干净地从网页中提取恰当数据的程序是一门手艺,而且通常是一门相当混乱的手艺。但回报是可以访问到以其他方式难以获取的可视化数据。以正确的方式接近,抓取甚至可能带来内在的满足感。

为什么我们需要抓取数据

在理想的虚拟世界中,在线数据会像图书馆一样有条理,通过一个复杂的杜威十进制系统对网页进行目录化。然而,对于热衷于数据狩猎的人来说,网络通常是有机地成长的,往往不受潜在的数据可视化者轻松访问的考虑所约束。因此,实际上,网络更像是一大堆数据,其中一些是干净且可用的(幸运的是,这个比例正在增加),但很多是设计不良、难以为人类消费的。而人类能够解析这种混乱、设计不良的数据,而我们相对笨拙的计算机却有些难以应对。^(5)

网络爬虫是关于制作选择模式,抓取我们想要的数据并且留下其他内容的过程。如果我们幸运的话,包含数据的网页会有有用的指针,比如具名表格,特定身份优于通用类等等。如果我们不幸的话,这些指针将会丢失,我们将不得不使用其他模式,或者在最坏的情况下,使用顺序指定符号,如主 div 中的第三个表格。显然,这些方法非常脆弱,如果有人在第三个表格之上添加了一个表格,它们就会失效。

在本节中,我们将解决一个小型网络爬虫任务,获取相同的诺贝尔奖获得者数据。我们将使用 Python 的最佳工具 Beautiful Soup 进行这次轻量级网络爬虫探险,将 Scrapy 这个重型武器留给下一章节。

注意

网页上存在数据和图片并不意味着它们一定是免费使用的。在我们的网络爬虫示例中,我们将使用允许在创意共享许可证下完全重用的维基百科。确保您爬取的任何内容可用,并且如有疑问,请联系网站维护者。您可能需要至少引用原始作者。

Beautiful Soup 和 lxml

Python 的主要轻量级网络爬虫工具是 Beautiful Soup 和 lxml。它们的主要选择语法不同,但令人困惑的是,每个工具都可以使用对方的解析器。一致的观点似乎是 lxml 的解析器速度要快得多,但 Beautiful Soup 在处理格式不良的 HTML 时可能更为强大。个人而言,我发现 lxml 足够强大,其基于xpaths的语法更加强大且通常更直观。我认为对于从网页开发背景来的人,熟悉 CSS 和 jQuery,基于 CSS 选择器的选择更加自然。根据您的系统,默认情况下 Beautiful Soup 通常使用 lxml 作为解析器。我们将在接下来的章节中使用它。

Beautiful Soup 是 Anaconda 软件包的一部分(见第一章),可以通过pip轻松安装:

$ pip install beautifulsoup4
$ pip install lxml

第一次网络爬虫探险

装备了 Requests 和 Beautiful Soup,让我们来做一个小任务,获取所有诺贝尔奖获得者的姓名、年份、类别和国籍。我们将从主维基百科诺贝尔奖页面开始。向下滚动显示了一个按年份和类别列出所有获奖者的表格,这是我们最低数据需求的良好起点。

一种 HTML 浏览器几乎是网页抓取的必备工具,我知道的最好的是 Chrome 的 Web 开发者工具中的元素选项卡(参见“元素选项卡”)。图 5-1 展示了涉及查询网页结构的关键元素。我们需要知道如何选择感兴趣的数据,比如维基百科的表格,同时避免页面上的其他元素。制作良好的选择器模式是有效抓取的关键,通过元素检查器突出显示 DOM 元素可以为我们提供 CSS 模式和 xpath(右键单击)。后者是 DOM 元素选择的特别强大的语法,也是我们工业强度抓取解决方案 Scrapy 的基础。

dpj2 0501

图 5-1. 维基百科的主要诺贝尔奖页面:A 和 B 展示了 wikitable 的 CSS 选择器。右键单击并选择 C(复制 XPath)会得到表格的 xpath(//*[@id="mw-content-text"]/table[1])。D 显示了由 jQuery 生成的thead标签。

获取 Soup

在抓取感兴趣的网页之前,您需要使用 Beautiful Soup 解析它,将 HTML 转换为标签树层次结构或 soup:

from bs4 import BeautifulSoup
import requests

BASE_URL = 'http://en.wikipedia.org'
# Wikipedia will reject our request unless we add
# a 'User-Agent' attribute to our http header.
HEADERS = {'User-Agent': 'Mozilla/5.0'}

def get_Nobel_soup():
    """ Return a parsed tag tree of our Nobel prize page """
    # Make a request to the Nobel page, setting valid headers
    response = requests.get(
        BASE_URL + '/wiki/List_of_Nobel_laureates',
        headers=HEADERS)
    # Return the content of the response parsed by Beautiful Soup
    return BeautifulSoup(response.content, "lxml") ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)

1

第二个参数指定了我们想要使用的解析器,即 lxml 的。

有了我们的 soup,让我们看看如何找到我们的目标标签。

选择标签

Beautiful Soup 提供了几种从解析后的 soup 中选择标签的方法,它们之间有微妙的差异,可能会让人感到困惑。在演示选择方法之前,让我们获取一下诺贝尔奖页面的 soup:

soup = get_Nobel_soup()

我们的目标表格(参见图 5-1)有两个定义类,wikitablesortable(页面上还有一些不可排序的表格)。我们可以使用 Beautiful Soup 的 find 方法来查找第一个具有这些类的表格标签。find 方法将一个标签名称作为其第一个参数,并将一个包含类、ID 和其他标识符的字典作为其第二个参数:

In[3]: soup.find('table', {'class':'wikitable sortable'})
Out[3]:
<table class="wikitable sortable">
<tr>
<th>Year</th>
...

尽管我们已经成功地通过类找到了我们的表格,但这种方法并不是很健壮。让我们看看当我们改变我们的 CSS 类的顺序时会发生什么:

In[4]: soup.find('table', {'class':'sortable wikitable'})
# nothing returned

因此,find关心类的顺序,使用类字符串来找到标签。如果类的顺序不同——这在 HTML 编辑中可能会发生,那么find就会失败。这种脆弱性使得很难推荐 Beautiful Soup 的选择器,比如findfind_all。在快速进行修改时,我发现 lxml 的CSS 选择器更容易和更直观。

使用 soup 的select方法(如果在创建时指定了 lxml 解析器),您可以使用其 CSS 类、ID 等指定 HTML 元素。此 CSS 选择器被转换为 lxml 内部使用的 xpath 语法。^(6)

要获取我们的 wikitable,我们只需在 soup 中选择一个表格,使用点符号表示其类:

In[5]: soup.select('table.sortable.wikitable')
Out[5]:
[<table class="wikitable sortable">
 <tr>
 <th>Year</th>
 ...
]

注意,select 返回一个结果数组,找到 soup 中所有匹配的标签。如果您只选择一个 HTML 元素,则 lxml 提供了 select_one 便捷方法。让我们抓取我们的诺贝尔表格,并看看它有哪些标题:

In[8]: table = soup.select_one('table.sortable.wikitable')

In[9]: table.select('th')
Out[9]:
[<th>Year</th>,
 <th width="18%"><a href="/wiki/..._in_Physics..</a></th>,
 <th width="16%"><a href="/wiki/..._in_Chemis..</a></th>,
 ...
]

作为 select 的简写,您可以直接在 soup 上调用标签;所以这两个是等效的:

table.select('th')
table('th')

使用 lxml 解析器,Beautiful Soup 提供了许多不同的过滤器来查找标签,包括我们刚刚使用的简单字符串名称、使用 正则表达式、使用标签名称列表等。更多详情请参阅这个 综合列表

除了 lxml 的 selectselect_one 外,BeautifulSoup 还提供了 10 个搜索解析树的便捷方法。这些方法本质上是 findfind_all 的变体,指定了它们搜索的树的哪些部分。例如,find_parentfind_parents 不是在树下查找后代,而是查找被搜索标签的父标签。所有这 10 个方法都在 Beautiful Soup 官方文档 中有详细说明。

现在我们知道如何选择我们的维基百科表格,并且掌握了 lxml 的选择方法,让我们看看如何制定一些选择模式来获取我们想要的数据。

制定选择模式

成功选择了我们的数据表格后,我们现在想要制定一些选择模式来抓取所需的数据。使用 HTML 浏览器,你可以看到个人得奖者被包含在 <td> 单元格中,并带有指向维基百科传记页面的 href <a> 链接(在个人情况下)。这是一个典型的目标行,具有我们可以用作目标以获取 <td> 单元格中数据的 CSS 类:

 <tr>
  <td align="center">
   1901
  </td>
  <td>
   <span class="sortkey">
    Röntgen, Wilhelm
   </span>
   <span class="vcard">
    <span class="fn">
     <a href="/wiki/Wilhelm_R%C3%B6ntgen" \
        title="Wilhelm Röntgen">
      Wilhelm Röntgen
     </a>
    </span>
   </span>
  </td>
  <td>
  ...
</tr>

如果我们循环遍历这些数据单元格,并跟踪它们的行(年份)和列(类别),那么我们应该能够创建一个获奖者列表,其中包含我们指定的所有数据,但不包括国籍。

以下 get_column_titles 函数从我们的表格中抓取了诺贝尔奖类别列标题,忽略了第一列 Year。通常,维基百科表格中的标题单元格包含一个带有 web 链接的 'a' 标签;所有诺贝尔奖类别都符合这个模型,指向它们各自的维基百科页面。如果标题不可点击,则我们存储其文本和一个空的 href:

def get_column_titles(table):
    """ Get the Nobel categories from the table header """
    cols = []
    for th in table.select_one('tr').select('th')[1:]: ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
        link = th.select_one('a')
        # Store the category name and any Wikipedia link it has
        if link:
            cols.append({'name':link.text,\
                         'href':link.attrs['href']})
        else:
            cols.append({'name':th.text, 'href':None})
    return cols

1

我们循环遍历表头,忽略第一列 Year ([1:])。这选择了 图 5-2 中显示的列标题。

dpj2 0502

图 5-2. 维基百科的诺贝尔奖得主表格

让我们确保 get_column_titles 正在给我们想要的东西:

get_column_titles(table)
Out:
[{'name': 'Physics', \
  'href': '/wiki/List_of_Nobel_laureates_in_Physics'},
 {'name': 'Chemistry',\
  'href': '/wiki/List_of_Nobel_laureates_in_Chemistry'},...
]

def get_Nobel_winners(table):
    cols = get_column_titles(table)
    winners = []
    for row in table.select('tr')[1:-1]: ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
        year = int(row.select_one('td').text) # Gets 1st <td>
        for i, td in enumerate(row.select('td')[1:]): ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
            for winner in td.select('a'):
                href = winner.attrs['href']
                if not href.startswith('#endnote'):
                    winners.append({
                        'year':year,
                        'category':cols[i]['name'],
                        'name':winner.text,
                        'link':winner.attrs['href']
                    })
    return winners

1

获取所有 Year 行,从第二行开始,对应于 图 5-2 中的行。

2

查找图 5-2 中显示的<td>数据单元格。

在年份行迭代中,我们获取第一个年份列,然后迭代剩余列,使用enumerate来跟踪我们的索引,该索引将映射到类别列名。我们知道所有获奖者的名称都包含在一个<a>标签中,但有时会有额外的以#endnote开头的<a>标签,我们需要过滤掉这些。最后,我们向数据数组中添加一个年份、类别、名称和链接的字典。请注意,获奖者选择器具有包含<a>标签的attrs字典,其中包括其他内容。

让我们确认get_Nobel_winners是否提供了诺贝尔奖获奖者字典的列表:

get_Nobel_winners(table)
[{'year': 1901,
  'category': 'Physics',
  'name': 'Wilhelm Röntgen',
  'link': '/wiki/Wilhelm_R%C3%B6ntgen'},
 {'year': 1901,
  'category': 'Chemistry',
  'name': "Jacobus Henricus van 't Hoff",
  'link': '/wiki/Jacobus_Henricus_van_%27t_Hoff'},
 {'year': 1901,
  'category': 'Physiologyor Medicine',
  'name': 'Emil Adolf von Behring',
  'link': '/wiki/Emil_Adolf_von_Behring'},
 {'year': 1901,
 ...}]

现在我们拥有了完整的诺贝尔奖获奖者列表和他们维基百科页面的链接,我们可以利用这些链接从个人传记中抓取数据。这将涉及大量请求,这不是我们真正希望多次执行的操作。明智和尊重的^(7)做法是缓存我们抓取的数据,使我们能够尝试各种抓取实验而无需返回维基百科。

缓存网页

在 Python 中轻松地制作一个快速缓存器是很容易的,但往往更容易找到由他人编写并慷慨捐赠给开源社区的更好解决方案。Requests 有一个名为requests-cache的不错插件,通过几行配置,可以满足所有基本的缓存需求。

首先,我们使用pip安装插件:

$ pip install --upgrade requests-cache

requests-cache使用猴子补丁技术,在运行时动态替换requests API 的部分。这意味着它可以透明地工作。您只需安装其缓存,然后像平常一样使用requests,所有缓存都会被处理。这是使用requests-cache的最简单方式:

import requests
import requests_cache

requests_cache.install_cache()
# use requests as usual...

install_cache方法有许多有用的选项,包括允许您指定缓存backendsqlitememorymongdbredis)或在缓存上设置过期时间(expiry_after)(单位为秒)。因此,以下代码将创建一个名为nobel_pages的缓存,使用sqlite作为后端,并设置页面在两小时(7,200 秒)后过期:

requests_cache.install_cache('nobel_pages',\
                         backend='sqlite', expire_after=7200)

requests-cache将满足大部分缓存需求,并且使用起来非常简单。有关更多详细信息,请参阅官方文档,您还可以在那里找到请求节流的小例子,这在进行大规模抓取时是一种有用的技术。

抓取获奖者的国籍

设置了缓存后,让我们尝试获取获奖者的国籍,使用前 50 个进行实验。一个小的get_winner_nationality()函数将使用我们之前存储的获奖者链接来抓取他们的页面,然后使用图 5-3 中显示的信息框来获取Nationality属性。

dpj2 0503

图 5-3. 抓取获奖者的国籍
注意

在抓取时,您正在寻找可靠的模式和包含有用数据的重复元素。正如我们将看到的那样,个人的维基信息框并不是一个可靠的来源,但是点击几个随机链接确实给人这样的印象。根据数据集的大小,进行一些实验性的健全性检查是很好的。您可以手动执行此操作,但是正如本章开头提到的那样,这不会扩展或提升您的技能。

示例 5-3 从之前抓取的获奖者字典中获取一个,并在找到Nationality键时返回一个带有名称标签的字典。让我们在前 50 名获奖者上运行它,并看看Nationality属性缺失的频率。

示例 5-3. 从获奖者传记页面抓取获奖者的国家
HEADERS = {'User-Agent': 'Mozilla/5.0'}

def get_winner_nationality(w):
    """ scrape biographic data from the winner's wikipedia page """
    response = requests.get('http://en.wikipedia.org' \
                                + w['link'], headers=HEADERS)
    content = response.content.decode('utf-8')
    soup = BeautifulSoup(content)
    person_data = {'name': w['name']}
    attr_rows = soup.select('table.infobox tr') ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
    for tr in attr_rows:                        ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
        try:
            attribute = tr.select_one('th').text
            if attribute == 'Nationality':
                person_data[attribute] = tr.select_one('td').text
        except AttributeError:
            pass

    return person_data

1

我们使用 CSS 选择器来查找所有具有infobox类的表格的所有<tr>行。

2

在行中循环查找国籍字段。

示例 5-4 表明,在前 50 名获奖者中,有 14 人未能成功抓取其国籍信息。在国际法学院的情况下,国籍可能并不重要,但西奥多·罗斯福就如同美国人一般。点击其中几个名字显示了问题(见图 5-4)。“国籍”缺失的标准传记格式意味着通常会使用国籍的同义词,例如玛丽·居里的公民身份;有时不会有参考文献,例如尼尔斯·芬森;而兰德尔·克雷默的信息框中仅有一张照片。我们可以丢弃信息框作为获奖者国籍的可靠来源,但是因为它们似乎是唯一的常规数据来源,这让我们不得不重新考虑。在下一章中,我们将看到使用 Scrapy 和不同起始页的成功方法。

示例 5-4. 测试抓取的国籍
wdata = []
# test first 50 winners
for w in winners[:50]:
    wdata.append(get_winner_nationality(w))
missing_nationality = []
for w in wdata:
    # if missing 'Nationality' add to list
    if not w.get('Nationality'):
        missing_nationality.append(w)
# output list
missing_nationality

[{'name': 'Theodor Mommsen'},
 {'name': 'Élie Ducommun'},
 {'name': 'Charles Albert Gobat'},
 {'name': 'Pierre Curie'},
 {'name': 'Marie Curie'},
 {'name': 'Niels Ryberg Finsen'},
 ...
 {'name': 'Theodore Roosevelt'}, ... ]

dpj2 0504

图 5-4. 没有记录国籍的获奖者

尽管维基百科相对自由,生产方面的数据设计是为了人类消费,您可能会发现缺乏严谨性。许多网站存在类似的陷阱,随着数据集的增大,可能需要更多的测试来发现集合模式中的缺陷。

尽管我们的第一个抓取练习有些人为,是为了介绍工具,我希望它捕捉到了一些网络抓取的稍微混乱的精神。我们最终未能获得可靠的诺贝尔数据集中的国籍字段,这可能可以通过一些网络浏览和手动 HTML 源代码检索来避免。然而,如果数据集规模更大,失败率稍低,那么编程检测,随着你熟悉抓取模块,确实开始变得更容易。

这个小小的抓取测试旨在介绍 Beautiful Soup,并显示我们寻找的数据收集需要更多的思考,这在抓取中经常发生。在下一章中,我们将使用我们在本节中学到的知识,推出大杀器 Scrapy,并收集我们需要的诺贝尔奖可视化数据。

总结

在本章中,我们已经看到了从网络中提取数据并存入 Python 容器、数据库或 pandas 数据集中的最常见方法的示例。Python 的 Requests 库是 HTTP 协商的真正工作马和我们数据可视化工具链中的一个基本工具。对于更简单的、符合 RESTful 标准的 API,使用 Requests 消耗数据只需几行 Python 代码。对于更为棘手的 API,比如那些可能有复杂授权的 API,像 Tweepy(用于 Twitter)这样的包装库可以省去许多麻烦。良好的包装库还可以跟踪访问速率,并在必要时限制你的请求。这是一个关键考虑因素,特别是当有可能会列入黑名单的不友好的消费者时。

我们还开始了我们的第一次数据抓取尝试,这通常是一个必要的后备措施,当不存在 API 并且数据是供人类消费时。在下一章中,我们将使用 Python 的 Scrapy 库,一个工业级的抓取库,获取本书可视化所需的所有诺贝尔奖数据。

^(1) 这实际上是开发人员的一个有意的策略

^(2) 仍可能有一些平台依赖性可能会产生错误。如果仍然遇到问题,这个Stack Overflow 线程是一个很好的起点。

^(3) OAuth1 访问最近已经被弃用。

^(4) 免费 API 当前限制为每小时约350 个请求

^(5) 许多现代机器学习和人工智能(AI)研究致力于创建能够处理杂乱、嘈杂、模糊、非正式数据的计算机软件,但截至本书出版时,我不知道有现成的解决方案。

^(6) 这种 CSS 选择语法对于使用过 JavaScript 的 jQuery library 的人应该很熟悉,它也类似于 D3 使用的语法。

^(7) 在抓取数据时,你使用了其他人的网络带宽,这最终会花费他们的金钱。试着限制你的请求次数是一种礼貌行为。

第六章:使用 Scrapy 进行大规模抓取

随着你的网络爬取目标变得更加宏大,使用 Beautiful Soup 和 requests 进行黑客式解决方案可能会非常快速变得非常混乱。管理作为请求生成更多请求的抓取数据变得棘手,如果你的请求是同步进行的,事情就会迅速变慢。一系列你可能没有预料到的问题开始显现出来。正是在这个时候,你需要转向一个强大、稳健的库来解决所有这些问题及更多。这就是 Scrapy 发挥作用的时候。

当 Beautiful Soup 是一个非常方便的快速且脏的抓取小工具时,Scrapy 是一个可以轻松进行大规模数据抓取的 Python 库。它拥有你期望的所有功能,例如内置缓存(带有过期时间)、通过 Python 的 Twisted web 框架进行异步请求、用户代理随机化等等。所有这些功能的代价是一个相当陡峭的学习曲线,而本章旨在通过一个简单的例子来平滑这条曲线。我认为 Scrapy 是任何数据可视化工具包的强大补充,真正为网络数据收集打开了可能性。

在“数据抓取”中,我们成功抓取了包含所有诺贝尔奖获得者姓名、年份和类别的数据集。我们对获奖者链接的传记页面进行了猜测性抓取,显示提取国籍将会很困难。在本章中,我们将把我们的诺贝尔奖数据的目标设定得更高一些,并且旨在爬取类似示例 6-1 所示形式的对象。

示例 6-1. 我们的目标诺贝尔 JSON 对象
{
  "category": "Physiology or Medicine",
  "country": "Argentina",
  "date_of_birth': "8 October 1927",
  "date_of_death': "24 March 2002",
  "gender": "male",
  "link": "http:\/\/en.wikipedia.org\/wiki\/C%C3%A9sar_Milstein",
  "name": "C\u00e9sar Milstein",
  "place_of_birth": "Bah\u00eda Blanca ,  Argentina",
  "place_of_death": "Cambridge , England",
  "text": "C\u00e9sar Milstein , Physiology or Medicine, 1984",
  "year": 1984
}

除了这些数据,我们还将尝试爬取获奖者的照片(如果适用)和一些简短的生物数据(参见图 6-1)。我们将使用这些照片和正文来为我们的诺贝尔奖可视化添加一些特色。

dpj2 0601

图 6-1. 爬取获奖者页面的目标

设置 Scrapy

Scrapy 应该是 Anaconda 包之一(参见第一章),所以你应该已经有它了。如果不是这样的话,你可以使用以下conda命令行来安装它:

$ conda install -c https://conda.anaconda.org/anaconda scrapy

如果你没有使用 Anaconda,只需快速安装pip即可完成任务:^(1)

$ pip install scrapy

安装了 Scrapy 后,你应该可以访问scrapy命令。与大多数 Python 库不同,Scrapy 设计为在爬取项目的上下文中通过命令行驱动,由配置文件、爬虫、管道等定义。让我们使用startproject选项为我们的诺贝尔奖爬取生成一个新项目。这将生成一个项目文件夹,所以确保你从一个合适的工作目录运行它:

$ scrapy startproject nobel_winners
New Scrapy project 'nobel_winners' created in:
    /home/kyran/workspace/.../scrapy/nobel_winners

You can start your first spider with:
    cd nobel_winners
    scrapy genspider example example.com

正如startproject的输出所说,你需要切换到nobel_winners目录,以便开始使用 Scrapy。

让我们来看看项目的目录树:

nobel_winners
├── nobel_winners
│   ├── __init__.py
│   ├── items.py
│   ├── middlewares.py
│   ├── pipelines.py
│   ├── settings.py
│   └── spiders
│       └── __init__.py
└── scrapy.cfg

如图所示,项目目录有一个同名的子目录,还有一个配置文件 scrapy.cfgnobel_winners 子目录是一个 Python 模块(包含一个 init.py 文件),其中有几个骨架文件和一个 spiders 目录,其中将包含您的抓取器。

建立目标

在“数据抓取”中,我们尝试从诺贝尔获奖者的传记页面中获取其国籍信息,但发现在许多情况下,这些信息要么缺失,要么标记不一致(请参见第五章)。与其间接获取国家数据,不如进行一点维基百科搜索。有一个页面按国家列出获奖者。这些获奖者以标题形式呈现,按顺序列出(请参见图 6-2),而不是以表格形式呈现,这使得恢复我们的基本姓名、类别和年份数据变得更加困难。此外,数据组织不是最理想的(例如,国家标题和获奖者列表并未分开形成有用的区块)。正如我们将看到的,一些结构良好的 Scrapy 查询将轻松地为我们提供所需的数据。

图 6-2 显示了我们第一个爬虫的起始页面及其将要定位的关键元素。国家名称标题(A)列表后面是他们获得诺贝尔奖的公民的有序列表(B)。

dpj2 0602

图 6-2. 通过国籍抓取维基百科的诺贝尔奖

为了抓取列表数据,我们需要启动 Chrome 浏览器的 DevTools(请参阅“元素标签”)并使用 Elements 标签及其检查器(放大镜)检查目标元素。图 6-3 显示了我们第一个爬虫的关键 HTML 目标:包含国家名称的标题(h2)和后面的获奖者列表(ol)。

dpj2 0603

图 6-3. 找到维基列表的 HTML 目标

使用 Xpath 定位 HTML 目标

Scrapy 使用xpaths来定义其 HTML 目标。Xpath 是描述 X(HT)ML 文档部分的语法,虽然它可能会变得相当复杂,但基础知识是直接的,并且通常能够完成手头的任务。

您可以通过使用 Chrome 的元素标签悬停在源上,然后右键单击并选择复制 XPath 来获取 HTML 元素的 xpath。例如,在我们的诺贝尔奖维基列表的国家名称(图 6-3 中的 h3)的情况下,选择阿根廷(第一个国家)的 xpath 如下所示:

//*[@id="mw-content-text"]/div[1]/h3[1]

我们可以使用以下 xpath 规则进行解码:

//E

文档中的任何元素 <E>(例如,//img 获取页面上的所有图片)

//E[@id="foo"]

选择 ID 为 foo 的元素 <E>

//*[@id="foo"]

选择任意带有 ID foo 的元素

//E/F[1]

元素 <E> 的第一个子元素 <F>

//E/*[1]

元素 <E> 的第一个子元素

遵循这些规则表明,我们的阿根廷标题//*[@id="mw-content-text"]/div[1]/h3[1]是具有 ID mw-content-text 的 DOM 元素的第一个div的第一个标题(h2)子元素。这相当于以下 HTML:

<div id="mw-content-text">
  <div>
    <h2>
        ...
    </h2>
  </div>
    ...
</div>

请注意,与 Python 不同,xpath 不使用从零开始的索引,而是将第一个成员设为1

使用 Scrapy Shell 测试 Xpath

正确使用 xpath 定位非常关键,对良好的抓取至关重要,并可能涉及一定程度的迭代。Scrapy 通过提供一个命令行 Shell 大大简化了这个过程,该 Shell 接受一个 URL,并创建一个响应上下文,在该上下文中可以尝试您的 xpath,如下所示:

$ scrapy shell
  https://en.wikipedia.org/wiki/List_of_Nobel_laureates_by_country

2021-12-09 14:31:06 [scrapy.utils.log] INFO: Scrapy 2.5.1 started
(bot: nobel_winners)
...

2021-12-09 14:31:07 [scrapy.core.engine] INFO: Spider opened
2021-12-09 14:31:07 [scrapy.core.engine] DEBUG: Crawled (200)
<GET https://en.wikip...List_of_Nobel_laureates_by_country>
(referer: None)
[s] Available Scrapy objects:

[s]   crawler  <scrapy.crawler.Crawler object at 0x3a8f510>
[s]   item {}
[s]   request    <GET https://...Nobel_laureates_by_country>
[s]   response   <200 https://...Nobel_laureates_by_country>
[s]   settings   <scrapy.settings.Settings object at 0x34a98d0>
[s]   spider     <DefaultSpider 'default' at 0x3f59190>

[s] Useful shortcuts:
[s]   shelp()   Shell help (print this help)
[s]   fetch(url[, redirect=True]) Fetch URL and update local objects
(by default, redirects are followed)
[s]   fetch(req)                  Fetch a scrapy.Request and update
[s]   view(response)    View response in a browser

In [1]:

现在我们有了一个基于 IPython 的 Shell,具有代码完成和语法高亮,可以在其中尝试我们的 xpath 定位。让我们抓取 wiki 页面上的所有<h3>标题:

In [1]: h3s = response.xpath('//h3')

结果的h3s是一个SelectorList,一个专门的 Pythonlist对象。让我们看看我们有多少个标题:

In [2]: len(h3s)
Out[2]: 91

我们可以获取第一个Selector对象,并通过在追加点后按 Tab 键,查询其在 Scrapy shell 中的方法和属性:

In [3] h3 = h3s[0]
In [4] h3.
attrib             get                re                 remove             ...
css                getall             re_first           remove_namespaces  ...
extract            namespaces         register_namespace response           ...

您经常会使用extract方法来获取 xpath 选择器的原始结果:

In [5]: h3.extract()
Out[6]:
u'<h3>
  <span class="mw-headline" id="Argentina">Argentina</span>
  <span class="mw-editsection">
  <span class="mw-editsection-bracket">
  ...
  </h3>'

这表明我们的国家标题从第一个<h3>开始,并包含一个类为mw-headlinespan。我们可以使用mw-headline类的存在作为我们国家标题的过滤器,内容作为我们的国家标签。让我们尝试一个 xpath,使用选择器的text方法从mw-headline span 中提取文本。请注意,我们使用xpath方法的<h3>选择器,使 xpath 查询相对于该元素:

In [7]: h3_arg = h3
In [8]: country = h3_arg.xpath(\
                         'span[@class="mw-headline"]/text()')\
.extract()
In [9]: country
Out[9]: ['Argentina']

extract方法返回可能的匹配列表,在我们的情况下是单个字符串'Argentina'。通过遍历h3s列表,我们现在可以获取我们的国家名称。

假设我们有一个国家的<h3>标题,现在我们需要获取其后跟的诺贝尔获奖者的有序列表(图 6-2 B)。方便的是,xpath 的following-sibling选择器正好可以做到这一点。让我们抓取阿根廷标题之后的第一个有序列表:

In [10]: ol_arg = h3_arg.xpath('following-sibling::ol[1]')
Out[10]: ol_arg
[<Selector xpath='following-sibling::ol[1]' data=u'<ol><li>
<a href="/wiki/C%C3%A9sar_Milst'>]

查看ol_arg的截断数据显示我们已选择了一个有序列表。请注意,即使只有一个Selectorxpath仍然会返回一个SelectorList。为了方便起见,通常直接选择第一个成员即可:

In [11]: ol_arg = h2_arg.xpath('following-sibling::ol[1]')[0]

现在我们已经有了有序列表,让我们获取其成员<li>元素的列表(截至 2022 年中):

In [12]: lis_arg = ol_arg.xpath('li')
In [13]: len(lis_arg)
Out[13]: 5

使用extract方法检查其中一个列表元素。作为第一个测试,我们要抓取获奖者的姓名,并捕获列表元素的文本:

In [14]: li = lis_arg[0] # select the first list element
In [15]: li.extract()
Out[15]:
'<li><a href="/wiki/C%C3%A9sar_Milstein"
         title="C\xe9sar Milstein">C\xe9sar Milstein</a>,
         Physiology or Medicine, 1984</li>'

提取列表元素显示了一个标准模式:获奖者维基百科页面的超链接名称,后跟逗号分隔的获奖类别和年份。获取获奖者姓名的一个稳健方法是选择列表元素第一个<a>标签的文本:

In [16]: name = li.xpath('a//text()')[0].extract()
In [17]: name
Out[17]: 'César Milstein'

常常需要获取例如列表元素中的所有文本,去除各种 HTML <a><span>和其他标签。descendant-or-self为我们提供了一个便捷的方法来执行此操作,生成后代文本的列表:

In [18]: list_text = li.xpath('descendant-or-self::text()')\
.extract()
In [19]: list_text
Out[19]: ['César Milstein', '*, Physiology or Medicine, 1984']

我们可以通过连接列表元素来获取完整的文本:

In [20]: ' '.join(list_text)
Out[20]: 'César Milstein *, Physiology or Medicine, 1984'

注意,list_text的第一项是获奖者的姓名,如果例如它缺少超链接,这给我们提供了另一种访问它的方式。

现在我们已经确定了我们的爬取目标(诺贝尔奖获得者的姓名和链接文本)的 xpath,让我们将它们合并到我们的第一个 Scrapy spider 中。

使用相对 Xpath 选择

正如刚才展示的,Scrapy 的xpath选择返回选择器的列表,这些选择器又有其自己的xpath方法。在使用xpath方法时,清楚相对选择和绝对选择非常重要。让我们通过诺贝尔页面的目录来明确这种区别。

目录具有以下结构:

<div id='toc'... >
  ...
   <ul ... >
     <li ... >
       <a href='Argentina'> ... </a>
     </li>
     ...
   </ul>
  ...
</div>

我们可以通过在响应上使用标准的xpath查询来选择诺贝尔 wiki 页面的目录,并获取带有 ID tocdiv

In [21]: toc = response.xpath('//div[@id="toc"]')[0]

如果我们想获取所有国家的<li>列表标签,可以在所选的toc div 上使用相对xpath。查看图 6-3 中的 HTML,显示国家无序列表ul是目录顶级列表的第二个列表项的第一个列表成员。以下等效的 xpath 可以选择此列表,两者都是相对于当前toc选择的子级:

In [22]: lis = toc.xpath('.//ul/li[2]/ul/li')
In [23]: lis = toc.xpath('ul/li[2]/ul/li')
In [24]: len(lis)
Out[24]: 81 # the number of countries in the table of contents (July 2022)

一个常见的错误是在当前选择上使用非相对xpath选择器,这会从整个文档中进行选择,在这种情况下获取所有无序(<ul><li>标签:

In [25]: lis = toc.xpath('//ul/li')
In [26]: len(lis)
OUt[26]: 271

在论坛上,由于混淆相对和非相对查询而导致的错误经常发生,因此非常重要要非常注意这种区别和观察那些点。

小贴士

获取目标元素的正确 xpath 表达式可能有些棘手,这些难点可能需要复杂的子句嵌套。使用一个写得很好的速查表在这里可以提供很大帮助,幸运的是有许多好的 xpath 速查表。可以在devhints.io找到一个非常好的选择。

第一个 Scrapy Spider

掌握了一些 xpath 知识,让我们制作我们的第一个爬虫,旨在获取获奖者的国家和链接文本(图 6-2 A 和 B)。

Scrapy 称其爬虫为spiders,每个爬虫都是项目spiders目录中的 Python 模块。我们将第一个爬虫称为nwinner_list_spider.py

.
├── nobel_winners
│   ├── __init__.py
│   ├── items.py
│   ├── middlewares.py
│   ├── pipelines.py
│   ├── settings.py
│   └── spiders
│       |── __init__.py
│       └── nwinners_list_spider.py <---
└── scrapy.cfg

爬虫是scrapy.Spider类的子类,放置在项目的spiders目录中,Scrapy 会自动检测到它们,并通过名称使其可通过scrapy命令访问。

在 示例 6-2 中显示的基本 Scrapy 爬虫遵循您将与大多数爬虫一起使用的模式。首先,您通过子类化 Scrapy item 创建字段来存储抓取的数据(第 A 部分在 示例 6-2)。然后,通过子类化 scrapy.Spider 创建一个命名的爬虫(第 B 部分在 示例 6-2)。调用 scrapy 命令行时,将使用爬虫的名称。每个爬虫都有一个 parse 方法,处理包含在 start_url 类属性中的起始 URL 的 HTTP 请求。在我们的情况下,起始 URL 是维基百科的诺贝尔奖得主页面。

示例 6-2. 第一个 Scrapy 爬虫
# nwinners_list_spider.py

import scrapy
import re
# A. Define the data to be scraped
class NWinnerItem(scrapy.Item):
    country = scrapy.Field()
    name = scrapy.Field()
    link_text = scrapy.Field()

# B Create a named spider
class NWinnerSpider(scrapy.Spider):
    """ Scrapes the country and link text of the Nobel-winners. """

    name = 'nwinners_list'
    allowed_domains = ['en.wikipedia.org']
    start_urls = [
        "http://en.wikipedia.org ... of_Nobel_laureates_by_country"
    ]
    # C A parse method to deal with the HTTP response
    def parse(self, response):

         h3s = response.xpath('//h3') ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)

         for h3 in h3s:
            country = h3.xpath('span[@class="mw-headline"]'\
            'text()').extract() ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
            if country:
                winners = h2.xpath('following-sibling::ol[1]') ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
                for w in winners.xpath('li'):
                    text = w.xpath('descendant-or-self::text()')\
                    .extract()
                    yield NWinnerItem(
                        country=country[0], name=text[0],
                        link_text = ' '.join(text)
                        )

1

获取页面上所有 <h3> 标题,其中大多数将是我们的目标国家标题。

2

在可能的情况下,获取带有类名为 mw-headline<h3> 元素的子 <span> 的文本。

3

获取国家获奖者列表。

示例 6-2 中的 parse 方法接收来自维基百科诺贝尔奖页面的响应,并生成 Scrapy 项目,然后将其转换为 JSON 对象并附加到输出文件,即一个 JSON 对象数组。

让我们运行我们的第一个爬虫,确保我们正确解析和抓取我们的诺贝尔数据。首先,导航到抓取项目的 nobel_winners 根目录(包含 scrapy.cfg 文件)。让我们看看可用的抓取爬虫有哪些:

$ scrapy list
nwinners_list

如预期,我们有一个 nwinners_list 爬虫位于 spiders 目录中。要启动它进行抓取,我们使用 crawl 命令并将输出重定向到 nwinners.json 文件。默认情况下,我们会得到许多伴随抓取的 Python 日志信息:

$ scrapy crawl nwinners_list -o nobel_winners.json
2021- ... [scrapy] INFO: Scrapy started (bot: nobel_winners)
...
2021- ... [nwinners_list] INFO: Closing spider (finished)
2021- ... [nwinners_list] INFO: Dumping Scrapy stats:
        {'downloader/request_bytes': 1147,
         'downloader/request_count': 4,
         'downloader/request_method_count/GET': 4,
         'downloader/response_bytes': 66459,
         ...
         'item_scraped_count': 1169, ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
2021- ...  [scrapy.core.engine] INFO: Spider closed (finished)

1

我们从页面上抓取了 1,169 名诺贝尔获奖者。

Scrapy crawl 的输出显示成功抓取了 1,169 个条目。让我们查看我们的 JSON 输出文件,确保事情按计划进行:

$ head nobel_winners.json
{"country": "Argentina",
  "link_text": "C\u00e9sar Milstein , Physiology or Medicine,"\
  " 1984",
  "name": "C\u00e9sar Milstein"},
 {"country": "Argentina",
  "link_text": "Adolfo P\u00e9rez Esquivel , Peace, 1980",
  "name": "Adolfo P\u00e9rez Esquivel"},
  ...

如您所见,我们有一个 JSON 对象数组,其中四个关键字段都是正确的。

现在我们已经有一个成功抓取页面上所有诺贝尔获奖者列表数据的爬虫,让我们开始优化它,以抓取我们用于诺贝尔奖可视化的所有目标数据(见[示例 6-1 和图 6-1)。

首先,让我们将我们计划抓取的所有数据作为字段添加到我们的 scrapy.Item 中:

...
class NWinnerItem(scrapy.Item):
    name = scrapy.Field()
    link = scrapy.Field()
    year = scrapy.Field()
    category = scrapy.Field()
    country = scrapy.Field()
    gender = scrapy.Field()
    born_in = scrapy.Field()
    date_of_birth = scrapy.Field()
    date_of_death = scrapy.Field()
    place_of_birth = scrapy.Field()
    place_of_death = scrapy.Field()
    text = scrapy.Field()
...

简化代码并使用专用函数 process_winner_li 处理获奖者链接文本也是明智的选择。我们将向其传递一个链接选择器和国家名称,并返回一个包含抓取数据的字典:

...

def parse(self, response):

    h3s = response.xpath('//h3')

    for h3 in h3s:
        country = h3.xpath('span[@class="mw-headline"]/text()')\
        .extract()
        if country:
            winners = h3.xpath('following-sibling::ol[1]')
            for w in winners.xpath('li'):
                wdata = process_winner_li(w, country[0])
                ...

在示例 6-3 中显示了process_winner_li方法。使用几个正则表达式从获奖者的li标签中提取信息,找到获奖年份和类别。

示例 6-3. 处理获奖者列表项
# ...
import re
BASE_URL = 'http://en.wikipedia.org'
# ...

def process_winner_li(w, country=None):
    """
    Process a winner's <li> tag, adding country of birth or
    nationality, as applicable.
    """
    wdata = {}
    # get the href link-address from the <a> tag
    wdata['link'] = BASE_URL + w.xpath('a/@href').extract()[0] ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)

    text = ' '.join(w.xpath('descendant-or-self::text()')\
         .extract())
    # get comma-delineated name and strip trailing whitespace
    wdata['name'] = text.split(',')[0].strip()

    year = re.findall('\d{4}', text) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
    if year:
        wdata['year'] = int(year[0])
    else:
        wdata['year'] = 0
        print('Oops, no year in ', text)

    category = re.findall(
            'Physics|Chemistry|Physiology or Medicine|Literature|'\
            'Peace|Economics',
                text) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
    if category:
        wdata['category'] = category[0]
    else:
        wdata['category'] = ''
        print('Oops, no category in ', text)

    if country:
         if text.find('*') != -1: ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/4.png)
             wdata['country'] = ''
             wdata['born_in'] = country
         else:
             wdata['country'] = country
             wdata['born_in'] = ''

    # store a copy of the link's text-string for any manual corrections
    wdata['text'] = text
    return wdata

1

要从列表项的<a>标签(<li><a href=*/wiki…​*>[winner name]</a>…​)中获取href属性,我们使用 xpath 属性引用 @。

2

这里,我们使用 Python 的内置正则表达式库re来查找列表项文本中的四位数字年份。

3

正则表达式库的另一个用途是在文本中查找诺贝尔奖类别。

4

获奖者姓名后面的星号用于指示国家是获奖者出生时的国家,而非国籍,如在澳大利亚列表中的"William Lawrence Bragg*,物理学,1915"

示例 6-3 返回主要维基百科诺贝尔奖按国家页面上所有获奖者的数据,包括姓名、年份、类别、国家(出生国或获奖时的国籍)以及个人获奖者页面的链接。我们需要使用最后这部分信息获取这些传记页面,并用它们来抓取我们剩余的目标数据(参见示例 6-1 和图 6-1)。

抓取个人传记页面

主要的维基百科诺贝尔奖按国家页面为我们提供了大量目标数据,但获奖者的出生日期、死亡日期(如适用)和性别仍需抓取。希望这些信息能够在他们的传记页面上(非组织获奖者)隐式或显式地获取。现在是启动 Chrome 的 Elements 选项卡并查看这些页面以确定如何提取所需数据的好时机。

我们在上一章中看到(第 5 章)个人页面上的可见信息框不是可靠的信息来源,而且经常完全缺失。直到最近^(3),一个隐藏的persondata表(参见图 6-4)相当可靠地提供了出生地点、死亡日期等信息。不幸的是,这个方便的资源已被弃用^(4)。好消息是,这是改进生物信息分类的一部分,通过在Wikidata上为其提供一个专门的空间,维基百科的结构化数据中心。

dpj2 0604

图 6-4. 诺贝尔奖获奖者的隐藏persondata

在 Chrome 的 Elements 标签页中检查维基百科的传记页面,显示了一个指向相关维基数据项的链接(见 图 6-5),它带你到保存在 https://www.wikidata.org 的传记数据。通过跟随这个链接,我们可以抓取那里找到的任何内容,我们希望那将是我们目标数据的主体部分——重要的日期和地点(见 示例 6-1)。

dpj2 0605

图 6-5. 超链接到获奖者的维基数据

跟随到维基数据的链接显示了一个页面,其中包含我们正在寻找的数据字段,例如我们的获奖者的出生日期。如 图 6-6 所示,这些属性嵌入在由计算机生成的 HTML 巢穴中,带有相关的代码,我们可以将其用作抓取标识符(例如,出生日期的代码为 P569)。

dpj2 0606

图 6-6. 维基数据的传记属性

如 图 6-7 所示,在这种情况下,我们想要的实际数据,即日期字符串,包含在 HTML 的另一个嵌套分支中,位于其相应的属性标签内。通过选择 div 并右键单击,我们可以存储元素的 xpath,并使用它告诉 Scrapy 如何获取它包含的数据。

dpj2 0607

图 6-7. 获取维基数据属性的 xpath

现在我们有了找到我们抓取目标所需的 xpath,让我们把所有这些放在一起,看看 Scrapy 如何链式处理请求,允许进行复杂的、多页面的抓取操作。

链接请求和数据产出

在本节中,我们将看到如何链式处理 Scrapy 请求,允许我们在进行数据抓取时跟随超链接。首先,让我们启用 Scrapy 的页面缓存。在尝试 xpath 目标时,我们希望限制对维基百科的调用次数,并且将我们抓取的页面存储起来是个好习惯。与某些数据集不同,我们的诺贝尔奖获得者每年只变一次。^(5)

缓存页面

如你所料,Scrapy 拥有一个复杂的缓存系统,可以精细地控制页面缓存(例如,允许你选择数据库或文件系统存储后端,页面过期前的时间等)。它被实现为启用在我们项目的 settings.py 模块中的中间件。有各种选项可用,但为了我们的诺贝尔奖抓取目的,简单地将 HTTPCACHE_ENABLED 设置为 True 就足够了:

# -*- coding: utf-8 -*-

# Scrapy settings for nobel_winners project
#
# This file contains only the most important settings by
# default. All the other settings are documented here:
#
#     http://doc.scrapy.org/en/latest/topics/settings.xhtml
#

BOT_NAME = 'nobel_winners'

SPIDER_MODULES = ['nobel_winners.spiders']
NEWSPIDER_MODULE = 'nobel_winners.spiders'

# Crawl responsibly by identifying yourself
# (and your website) on the user-agent
#USER_AGENT = 'nobel_winners (+http://www.yourdomain.com)'

HTTPCACHE_ENABLED = True

查看完整的 Scrapy 中间件范围,请参阅Scrapy 文档

在勾选了缓存框后,让我们看看如何链式处理 Scrapy 请求。

产出请求

我们现有的 spider 的parse方法循环遍历诺贝尔获奖者,使用process_winner_li方法来抓取国家、姓名、年份、类别和传记超链接字段。我们现在想要使用传记超链接来生成一个 Scrapy 请求,以获取生物页面并将其发送到一个自定义的抓取方法。

Scrapy 实现了一种 Python 风格的请求链接模式,利用 Python 的yield语句创建生成器,^(6) 使得 Scrapy 能够轻松处理我们所做的任何额外页面请求。示例 6-4 展示了该模式的实际应用。

示例 6-4. 使用 Scrapy 进行请求链接
class NWinnerSpider(scrapy.Spider):
    name = 'nwinners_full'
    allowed_domains = ['en.wikipedia.org']
    start_urls = [
        "https://en.wikipedia.org/wiki/List_of_Nobel_laureates" \
        "_by_country"
    ]

    def parse(self, response):

        h3s = response.xpath('//h3')
        for h3 in h3s:
            country = h3.xpath('span[@class="mw-headline"]/text()')
                      .extract()
            if country:
                winners = h2.xpath('following-sibling::ol[1]')
                for w in winners.xpath('li'):
                    wdata = process_winner_li(w, country[0])
                    request = scrapy.Request( ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
                        wdata['link'],
                        callback=self.parse_bio, ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
                        dont_filter=True)
                    request.meta['item'] = NWinnerItem(**wdata) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
                    yield request ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/4.png)

    def parse_bio(self, response):
        item = response.meta['item'] ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/5.png)
        ...

1

发起对获奖者传记页面的请求,使用从process_winner_li中抓取的链接(wdata[*link*])。

2

设置回调函数以处理响应。

3

创建一个 Scrapy Item 来保存我们的诺贝尔数据,并使用刚刚从process_winner_li中抓取的数据进行初始化。将此Item数据附加到请求的元数据中,以允许任何响应访问它。

4

通过yield请求,使得parse方法成为可消费请求的生成器。

5

此方法处理来自我们生物链接请求的回调。为了将抓取的数据添加到我们的 Scrapy Item 中,我们首先从响应的元数据中检索它。

我们对“抓取个人传记页面”中的维基百科页面的调查表明,我们需要从其传记页面中找到获奖者的 Wikidata 链接,并使用它来生成请求。然后,我们将从响应中抓取日期、地点和性别数据。

示例 6-5 展示了parse_bioparse_wikidata这两种方法,用于抓取我们获奖者的生物数据。parse_bio使用抓取的 Wikidata 链接请求 Wikidata 页面,并将请求作为parse方法中也使用yield返回的request进行返回。在请求链的末尾,parse_wikidata获取项目并填充来自 Wikidata 的任何可用字段,最终将项目yield给 Scrapy。

示例 6-5. 解析获奖者的传记数据
# ...

    def parse_bio(self, response):

        item = response.meta['item']
        href = response.xpath("//li[@id='t-wikibase']/a/@href") ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
               .extract()
        if href:
            url = href[0] ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
            wiki_code = url.split('/')[-1]
            request = scrapy.Request(href[0],\
                          callback=self.parse_wikidata,\ ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
                          dont_filter=True)
            request.meta['item'] = item
            yield request

    def parse_wikidata(self, response):

        item = response.meta['item']
        property_codes =  ![4
            {'name':'date_of_birth', 'code':'P569'},
            {'name':'date_of_death', 'code':'P570'},
            {'name':'place_of_birth', 'code':'P19', 'link':True},
            {'name':'place_of_death', 'code':'P20', 'link':True},
            {'name':'gender', 'code':'P21', 'link':True}
          ]

        for prop in property_codes:

            link_html = ''
            if prop.get('link'):
                link_html = '/a'
            # select the div with a property-code id
            code_block = response.xpath('//*[@id="%s"]'%(prop['code']))
            # continue if the code_block exists
            if code_block:
            # We can use the css selector, which has superior class
            # selection
                values = code_block.css('.wikibase-snakview-value')
            # the first value corresponds to the code property\
            # (e.g., '10 August 1879')
                value = values[0]
                prop_sel = value.xpath('.%s/text()'%link_html)
                if prop_sel:
                    item[prop['name']] = prop_sel[0].extract()

        yield item ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/5.png)

1

提取在图 6-5 中标识的 Wikidata 链接。

2

从 URL 中提取wiki_code,例如,http://wikidata.org/wiki/Q155525 → Q155525。

3

使用 Wikidata 链接生成一个请求,其中我们的 spider 的parse_wikidata作为回调处理响应。

4

这些是我们之前找到的属性代码(见图 6-6),名称对应于我们 Scrapy 项目NWinnerItem中的字段。具有True link属性的属性包含在<a>标签中。

5

最后,我们返回项目,此时应该已从维基百科获得了所有目标数据。

现在我们的请求链已经建立,让我们检查一下爬虫是否正在爬取我们需要的数据:

$ scrapy crawl nwinners_full
2021-... [scrapy] ... started (bot: nobel_winners)
...
2021-... [nwinners_full] DEBUG: Scraped from
         <200 https://www.wikidata.org/wiki/Q155525>
  {'born_in': '',
   'category': u'Physiology or Medicine',
   'date_of_birth': u'8 October 1927',
   'date_of_death': u'24 March 2002',
   'gender': u'male',
   'link': u'http://en.wikipedia.org/wiki/C%C3%A9sar_Milstein',
   'name': u'C\xe9sar Milstein',
   'country': u'Argentina',
   'place_of_birth': u'Bah\xeda Blanca',
   'place_of_death': u'Cambridge',
   'text': u'C\xe9sar Milstein , Physiology or Medicine, 1984',
   'year': 1984}
2021-... [nwinners_full] DEBUG: Scraped from
         <200 https://www.wikidata.org/wiki/Q193672>
 {'born_in': '',
  'category': u'Peace',
  'date_of_birth': u'1 November 1878',
  'date_of_death': u'5 May 1959',
  'gender': u'male',
  'link': u'http://en.wikipedia.org/wiki/Carlos_Saavedra_Lamas',
  ...

看起来一切都很顺利。除了born_in字段外,该字段依赖于主维基百科诺贝尔奖获奖者列表中是否有一个带有星号的名称,我们获得了所有我们目标的数据。这个数据集现在已准备好在接下来的章节中由 pandas 进行清理。

现在我们已经为诺贝尔奖获奖者爬取了基本的传记数据,让我们继续爬取我们的其余目标,包括一些传记正文和伟大男女士的图片(如果有的话)。

Scrapy 管道

为了为我们的诺贝尔奖可视化增添一点个性,最好有一些获奖者的简介文字和一张图片。维基百科的传记页面通常提供这些内容,所以让我们开始爬取它们吧。

到目前为止,我们爬取的数据都是文本字符串。为了爬取各种格式的图片,我们需要使用一个 Scrapy pipeline管道提供了一种对我们爬取的项目进行后处理的方法,您可以定义任意数量的管道。您可以编写自己的管道,或者利用 Scrapy 已提供的管道,比如我们将要使用的ImagesPipeline

在其最简单的形式中,一个管道只需定义一个process_item方法。这个方法接收到爬取的项目和爬虫对象。让我们编写一个小管道来拒绝无性别的诺贝尔奖获得者(这样我们就可以省略掉授予组织而不是个人的奖项),使用我们现有的nwinners_full爬虫来传递项目。首先,我们将一个DropNonPersons管道添加到我们项目的pipelines.py模块中:

# nobel_winners/nobel_winners/pipelines.py

# Define your item pipelines here
#
# Don't forget to add your pipeline to the ITEM_PIPELINES setting
# See: http://doc.scrapy.org/en/latest/topics/item-pipeline.xhtml

from scrapy.exceptions import DropItem

class DropNonPersons(object):
    """ Remove non-person winners """

    def process_item(self, item, spider):
        if not item['gender']:               ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
            raise DropItem("No gender for %s"%item['name'])
        return item ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)

1

如果我们爬取的项目在 Wikidata 上找不到性别属性,那么它很可能是一个组织,比如红十字会。我们的可视化重点是个人获奖者,所以在这里我们使用DropItem将该项目从输出流中删除。

2

我们需要将项目返回到进一步的管道或由 Scrapy 保存。

pipelines.py头部所述,为了将此管道添加到我们项目的爬虫中,我们需要在settings.py模块中将其注册到一个管道的dict中,并设置为活动状态(1):

# nobel_winners/nobel_winners/settings.py

BOT_NAME = 'nobel_winners'
SPIDER_MODULES = ['nobel_winners.spiders']
NEWSPIDER_MODULE = 'nobel_winners.spiders'

HTTPCACHE_ENABLED = True
ITEM_PIPELINES = {'nobel_winners.pipelines.DropNonPersons':300}

现在我们已经为我们的项目创建了基本的管道工作流程,让我们向项目添加一个有用的管道。

使用管道爬取文本和图片

现在我们想要抓取获奖者的传记和照片(见 Figure 6-1),如果有的话。我们可以使用与上一个爬虫相同的方法来抓取传记文本,但是最好使用图像管道来处理照片。

我们可以轻松编写自己的管道,以获取抓取的图像 URL,从维基百科请求并保存到磁盘,但要正确执行则需要一些小心。例如,我们希望避免重新下载最近下载过或在此期间未更改的图像。指定存储图像位置的灵活性是一个有用的功能。此外,最好有将图像转换为常见格式(例如 JPG 或 PNG)或生成缩略图的选项。幸运的是,Scrapy 提供了一个 ImagesPipeline 对象,具备所有这些功能及更多。这是其 媒体管道 之一,还包括用于处理一般文件的 FilesPipeline

我们可以将图像和传记文本抓取添加到现有的 nwinners_full 爬虫中,但这开始变得有点庞大,从更正式的类别中分离此字符数据是有意义的。因此,我们将创建一个名为 nwinners_minibio 的新爬虫,它将重用前一个爬虫的 parse 方法的部分,以便循环遍历诺贝尔获奖者。

像往常一样,创建 Scrapy 爬虫时,我们的第一步是获取我们的抓取目标的 XPath——在这种情况下,如果有的话,这是获奖者传记文本的第一部分和他们的照片。为此,我们启动 Chrome Elements 并探索传记页面的 HTML 源代码,查找在 Figure 6-8 中显示的目标。

dpj2 0608

图 6-8. 我们传记抓取的目标元素:传记的第一部分(A)由一个停止点(B)标记,以及获奖者的照片(C)
Example 6-6. 抓取传记文本
<div id="mw-content-text">
  <div class="mw-parser-output">
    ...
    <table class="infobox biography vcard">...</table>
    /* target paragraphs: */
    <p>...</p>
    <p>...</p>
    <p>...</p>
    <div id="toc">...</div>
  ...
  </div>
</div>

使用 Chrome Elements 进行调查(参见 Example 6-6)显示,传记文本(Figure 6-8 A)包含在 mw-parser-output 类的子段落中,后者是具有 ID mw-content-textdiv 的子 div。这些段落夹在一个具有类 infoboxtable 和具有 ID toc 的目录 div 之间。我们可以使用 following-siblingpreceding-sibling 运算符来创建一个选择器,以捕获目标段落:

  ps = response.xpath(\
    '//*[@id="mw-content-text"]/div/table/following-sibling::p' ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
    '[not(preceding-sibling::div[@id="toc"])]').extract() ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)

1

第一个 div 的第一个表格之后的所有段落,它是 mw-content-text ID 的子 div 中的一个。

2

排除(不包括)所有具有前置兄弟 div 和 ID toc 的段落。

用 Scrapy shell 测试后,它始终捕获了诺贝尔获奖者的小传。

进一步探索获奖者页面显示,他们的照片(Figure 6-8 C)位于 infobox 类的表格中,并且是该表格中唯一的图像标签(<img>):

<table class="infobox biography vcard">
  ...
        <img alt="Francis Crick crop.jpg" src="//upload..." />
  ...
</table>

xpath '//table[contains(@class,"infobox")]//img/@src' 将获取图像的源地址。

与我们的第一个蜘蛛一样,我们首先需要声明一个 Scrapy Item 来保存我们爬取的数据。我们将爬取获奖者的传记链接和姓名,这些可以作为图像和文本的标识符使用。我们还需要一个地方来存储我们的 image-urls(尽管我们只会爬取一个生物图像,我将涵盖多图像使用情况),结果图像引用(文件路径),以及一个 bio_image 字段来存储我们感兴趣的特定图像:

import scrapy
import re

BASE_URL = 'http://en.wikipedia.org'

class NWinnerItemBio(scrapy.Item):
    link = scrapy.Field()
    name = scrapy.Field()
    mini_bio = scrapy.Field()
    image_urls = scrapy.Field()
    bio_image = scrapy.Field()
    images = scrapy.Field()
...

现在我们重复使用我们的诺贝尔奖获得者爬取循环(详见 Example 6-4),这次生成请求到我们的新 get_mini_bio 方法,它将爬取图像 URL 和传记文本:

class NWinnerSpiderBio(scrapy.Spider):

    name = 'nwinners_minibio'
    allowed_domains = ['en.wikipedia.org']
    start_urls = [
        "https://en.wikipedia.org/wiki/List_of_Nobel_" \
        "laureates_by_country"
    ]

    def parse(self, response):

        filename = response.url.split('/')[-1]
        h3s = response.xpath('//h3')

        for h3 in h3s:
            country = h3.xpath('span[@class="mw-headline"]'\
            'text()').extract()
            if country:
                winners = h3.xpath('following-sibling::ol[1]')
                for w in winners.xpath('li'):
                    wdata = {}
                    wdata['link'] = BASE_URL + \
                    w.xpath('a/@href').extract()[0]
                    # Process the winner's bio page with
                    # the get_mini_bio method
                    request = scrapy.Request(wdata['link'],
                                  callback=self.get_mini_bio)
                    request.meta['item'] = NWinnerItemBio(**wdata)
                    yield request

我们的 get_mini_bio 方法将为 image_urls 列表添加任何可用的照片 URL,并将传记的所有段落添加到项目的 mini_bio 字段,直到 <p></p> 结束点:

...
    def get_mini_bio(self, response):
        """ Get the winner's bio text and photo """

        BASE_URL_ESCAPED = 'http:\/\/en.wikipedia.org'
        item = response.meta['item']
        item['image_urls'] = []
        img_src = response.xpath(\
            '//table[contains(@class,"infobox")]//img/@src') ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
        if img_src:
            item['image_urls'] = ['http:' +\
             img_src[0].extract()]

        ps = response.xpath(
            '//*[@id="mw-content-text"]/div/table/'
            'following-sibling::p[not(preceding-sibling::div[@id="toc"])]')\
            .extract() ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
        # Concatenate the biography paragraphs for a mini_bio string
        mini_bio = ''
        for p in ps:
            mini_bio += p
        # correct for wiki-links
        mini_bio = mini_bio.replace('href="/wiki', 'href="'
                       + BASE_URL + '/wiki"') ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
        mini_bio = mini_bio.replace('href="#',\
         'href="' + item['link'] + '#"')
        item['mini_bio'] = mini_bio
        yield item

1

定位到 infobox 类中的第一个(也是唯一的)图像,并获取其源(src)属性(例如 <img src='//upload.wikime⁠dia.org/​.../Max_Perutz.jpg'...)。

2

抓取我们的迷你传记段落在兄弟夹心中。

3

替换维基百科内部的 href(例如 /wiki/…​)为我们的可视化所需的完整地址。

定义了我们的生物爬虫后,我们需要创建其相应的管道,用于将爬取的图像 URL 转换为保存的图像。我们将使用 Scrapy 的 images pipeline 完成这项任务。

Example 6-7 中展示的 ImagesPipeline 有两个主要方法,get_media_requests 用于生成图像 URL 的请求,以及 item_completed 在请求消耗后调用。

Example 6-7. 使用图像管道爬取图像
import scrapy
from itemadapter import ItemAdapter
from scrapy.pipelines.images import ImagesPipeline
from scrapy.exceptions import DropItem

class NobelImagesPipeline(ImagesPipeline):

    def get_media_requests(self, item, info): ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)

        for image_url in item['image_urls']:
            yield scrapy.Request(image_url)

    def item_completed(self, results, item, info): ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)

        image_paths = [img['path'] for ok, img in results if ok] ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)

        if not image_paths:
            raise DropItem("Item contains no images")
        adapter = ItemAdapter(item) ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/4.png)
        adapter['bio_image'] = image_paths[0]

        return item

1

此操作获取由我们的 nwinners_minibio 蜘蛛爬取的任何图像 URL,并生成其内容的 HTTP 请求。

2

图像 URL 请求完成后,结果将传递给 item_completed 方法。

3

此 Python 列表推导式用于过滤结果元组的列表(形如 [(True, Image), (False, Image) …​]),筛选出成功的并将其文件路径存储在 settings.pyIMAGES_STORE 变量指定的目录相对路径下。

4

我们使用一个 Scrapy item adapter,它为支持的项目类型提供了一个公共接口。

现在我们已经定义了爬虫和管道,我们只需要将管道添加到我们的 settings.py 模块,并将 IMAGES_STORE 变量设置为我们想要保存图像的目录:

# nobel_winners/nobel_winners/settings.py

...
ITEM_PIPELINES = {'nobel_winners.pipelines'\
                  '.NobelImagesPipeline':300}
IMAGES_STORE = 'images'

让我们从我们项目的 nobel_winners 根目录运行我们的新爬虫,并检查其输出:

$ scrapy crawl nwinners_minibio -o minibios.json
...
2021-12-13 17:18:05 [scrapy.core.scraper] DEBUG: Scraped from
    <200 https://en.wikipedia.org/wiki/C%C3%A9sar_Milstein>

{'bio_image': 'full/65ac9541c305ab4728ed889385d422a2321a117d.jpg',
 'image_urls': ['http://upload.wikimedia...
 150px-Milstein_lnp_restauraci%C3%B3n.jpg'],
 'link': 'http://en.wikipedia.org/wiki/C%C3%A9sar_Milstein',
 'mini_bio': '<p><b>César Milstein</b>, <a ...'
             'href="http://en.wikipedia.org/wiki/Order_of_the_...'
             'title="Order of the Companions of Honour">CH</a>'
             'href="http://en.wikipedia.org/wiki/Royal_Society'
             'Society">FRS</a><sup id="cite_ref-frs_2-1" class'...>
             'href="http://en.wikipedia.org/wiki/C%C3%A9sar_Mi'
             '(8 October 1927 – 24 March 2002) was an <a ...>'
             'href="http://en.wikipedia.org/wiki/Argentine" '

...

正确地收集了迷你传记和诺贝尔获奖者的照片,并利用其图像管道进行处理。图像存储在 image_urls 中,并成功处理,加载了存储在我们指定的 IMAGE_STORE 目录中的 JPG 文件的相对路径(full/a5f763b828006e704cb291411b8b643bfb1886c.jpg)。恰好,文件名是图像 URL 的 SHA1 哈希,这使得图像管道能够检查现有的图像,并防止冗余请求。

我们的图像目录的快速列表显示了一系列美观的维基百科诺贝尔奖获得者图像,已准备好在我们的网络可视化中使用:

$ (nobel_winners) tree images
images
└── full
    ├── 0512ae11141584da1262661992a1b05dfb20dd52.jpg
    ├── 092a92689118c16b15b1613751af422439df2850.jpg
    ├── 0b6a8ca56e6ff115b7d30087df9c21da09684db1.jpg
    ├── 1197aa95299a1fec983b3dbdeaeb97a1f7e545c9.jpg
    ├── 1f6fb8e9e2241733da47328291b25bd1a78fa588.jpg
    ├── 272cf1b089c7a28ea0109ad8655bc3ef1c03fb52.jpg
    ├── 28dcc7978d9d5710f0c29d6dfcf09caa7e13a1d0.jpg
    ...

正如我们将在 Chapter 16 中看到的那样,我们将把它们放在我们 web 应用的 static 文件夹中,以便通过获奖者的 bio_image 字段访问。

有了我们手头的图像和传记文本,我们已经成功地完成了本章初时设定的所有目标(参见 Example 6-1 和 Figure 6-1)。现在,在使用 pandas 的帮助下清理这些不可避免的脏数据之前,我们来进行一个快速总结。

指定多个爬虫的管道

在我们的 Scrapy 项目中启用的管道适用于所有爬虫。通常,如果你有多个爬虫,你可能希望能够指定哪些管道适用于每个爬虫。有几种方法可以实现这一点,但我见过的最好的方法是使用爬虫的 custom_settings 类属性来设置 ITEM_PIPELINES 字典,而不是在 settings.py 中设置它。对于我们的 nwinners_minibio 爬虫来说,这意味着像这样调整 NWinnerSpiderBio 类:

class NWinnerSpiderBio(scrapy.Spider):
    name = 'nwinners_minibio'
    allowed_domains = ['en.wikipedia.org']
    start_urls = [
      "http://en.wikipedia.org/wiki"\
      "List_of_Nobel_laureates_by_country"
    ]

    custom_settings = {
        'ITEM_PIPELINES':\
        {'nobel_winners.pipelines.NobelImagesPipeline':1}
    }

    # ...

现在 NobelImagesPipeline 管道只会在爬取诺贝尔奖获得者传记时应用。

概要

在本章中,我们创建了两个 Scrapy 爬虫,成功抓取了我们的诺贝尔奖获得者的简单统计数据,以及一些传记文本(如果有的话,还有一张照片,为数据增添一些色彩)。Scrapy 是一个功能强大的库,它能够处理你在一个完整的爬虫中可能需要的一切。虽然使用 Scrapy 的工作流比用 Beautiful Soup 进行一些简单的操作需要更多的努力来实现,但是随着你的爬取需求增加,Scrapy 的强大之处也会显现出来。所有的 Scrapy 爬虫都遵循这里演示的标准流程,并且在编写了几个爬虫后,这种工作流程应该会变得日常化。

我希望本章传达了网页抓取的相当“hacky”、迭代性质,以及在从网上常见的混乱数据中生成相对干净数据时所能获得的某些安静满足感。事实上,现在和可预见的未来,大多数有趣的数据(数据可视化艺术和科学的燃料)都困在一种对于本书关注的基于网络的可视化来说是不可用的形式中。从这个意义上说,网页抓取是一种解放性的努力。

我们抓取的数据,其中大部分是人工编辑的,肯定会有一些错误——从格式不正确的日期到分类异常和缺失字段。下一章基于 pandas 的重点是使数据变得可呈现。但首先,我们需要简要介绍一下 pandas 及其构建模块 NumPy。

^(1) 查看 Scrapy 安装文档 获取特定平台的详细信息。

^(2) 有一些方便的在线工具可以用来测试正则表达式,其中一些是特定于编程语言的。Pyregex 是一个不错的 Python 工具,包含一个方便的速查表。

^(3) 作者被这次删除搞得很烦。

^(4) 查看 Wikipedia 获取解释。

^(5) 严格来说,维基百科社区不断进行编辑,但基本细节应该稳定直到下一批奖项。

^(6) 参阅 Jeff Knupp 的博客,“Everything I Know About Python”,了解 Python 生成器和 yield 的使用。

第三部分:使用 pandas 清理和探索数据

在本书的这一部分中,我们工具链的第二阶段(见图 III-1),我们将刚刚用 Scrapy 爬取的诺贝尔奖数据集首先进行清理,然后探索其中有趣的信息。我们将要使用的主要工具是庞大的 Python 库 Matplotlib 和 pandas。

注意

本书的第二版使用了在第一版中爬取的相同的诺贝尔数据集。认为把时间用于撰写新材料和更新所有库要比更改探索和分析更有价值。数据可视化通常涉及与旧数据集一起工作,少数额外的诺贝尔获奖者完全不改变材料的实质。

pandas 将在接下来的几章中介绍,与其基础模块 NumPy 一起。在第九章中,我们将使用 pandas 来清理诺贝尔奖数据集。然后在第十一章中,与 Python 的绘图库 Matplotlib 一起,我们将使用它来探索数据。

在第四部分,我们将看到如何使用 Python 的 Flask Web 服务器将经过清理的诺贝尔奖数据集传递到浏览器。

dpj2 p309

图 III-1. 我们的数据可视化工具链:数据清理和探索
提示

你可以在书籍的 GitHub 仓库找到本书这部分的代码。

第七章:介绍 NumPy

本章旨在向不熟悉的人介绍 Numeric Python 库(NumPy)。NumPy 是 pandas 的关键构建模块,pandas 是一个强大的数据分析库,我们将在接下来的章节中使用它来清理和探索最近抓取的诺贝尔奖数据集(参见 第六章)。如果你想充分利用 pandas,对 NumPy 的核心元素和原则有基本的了解是很重要的。因此,本章的重点是为即将介绍 pandas 的内容奠定基础。

NumPy 是一个 Python 模块,允许访问非常快速的、多维数组操作,由 C 和 Fortran 编写的底层库实现。^(1) Python 对大量数据的本地性能相对较慢,但 NumPy 允许您一次在大数组上执行并行操作,使其非常快速。鉴于 NumPy 是大多数重量级 Python 数据处理库的主要构建模块,包括 pandas,很难否认它作为 Python 数据处理世界的枢纽的地位。

除了 pandas,NumPy 的庞大生态系统还包括科学 Python(SciPy),它用硬核科学和工程模块补充了 NumPy;scikit-learn,它添加了许多现代机器学习算法,如分类和特征提取;以及许多其他专门库,它们使用 NumPy 的多维数组作为其主要数据对象。在这个意义上,基本的 NumPy 掌握可以极大地扩展你在数据处理领域的 Python 范围。

理解 NumPy 的关键在于其数组。如果你了解这些是如何工作的以及如何操作它们,那么其他很多东西应该会很容易跟上。^(2) 接下来的几节将涵盖基本的数组操作,并举几个 NumPy 实例,为 第八章 中 pandas 数据集的介绍做铺垫。

NumPy 数组

在 NumPy 中,一切都是围绕其同构^(3)、多维的 ndarray 对象构建的。对这些数组的操作是使用非常快速、编译的库执行的,使得 NumPy 能够大大超越原生 Python。除此之外,你可以对这些数组执行标准算术,就像你对一个 Python intfloat 执行的一样。^(4) 在下面的代码中,整个数组被添加到自身,就像添加两个整数一样容易和快速:

import numpy as np ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)

a = np.array([1, 2, 3]) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
a + a
# output array([2, 4, 6])

1

使用 NumPy 库的标准方法,远远优于 "from numpy import *"。^(5)

2

自动将 Python 列表转换为数字。

在幕后,NumPy 可以利用现代 CPU 可用的大规模并行计算,允许例如在可接受的时间内压缩大矩阵(2D 数组)。

NumPy ndarray的关键属性是其维数(ndim)、形状(shape)和数值类型(dtype)。同一组数字数组可以在原地重塑,有时这将涉及更改数组的维数。让我们使用print_array_details方法演示一些重塑的例子,用一个小八元素数组:

def print_array_details(a):
    print('Dimensions: %d, shape: %s, dtype: %s'\
        %(a.ndim, a.shape, a.dtype))

首先,我们将创建我们的一维数组。如打印的细节所示,默认情况下,这具有 64 位整数数值类型(int64):

In [1]: a = np.array([1, 2, 3, 4, 5, 6, 7, 8])

In [2]: a
Out[2]: array([1, 2, 3, 4, 5, 6, 7, 8])

In [3]: print_array_details(a)
Dimensions: 1, shape: (8,), dtype: int64

使用reshape方法,我们可以改变a的形状和维度数量。让我们将a重塑为一个由两个四元素数组组成的二维数组:

In [4]: a = a.reshape([2, 4])
In [5]: a
Out[5]:
array([[1, 2, 3, 4],
       [5, 6, 7, 8]])

In [6]: print_array_details(a)
Dimensions: 2, shape: (2, 4), dtype: int64

一个八元素数组也可以重塑为三维数组:

In [7]: a = a.reshape([2, 2, 2])

In [8]: a
Out[8]:
array([[[1, 2],
        [3, 4]],

       [[5, 6],
        [7, 8]]])

In [9]: print_array_details(a)
Dimensions: 3, shape: (2, 2, 2), dtype: int64

形状和数值类型可以在创建数组时或以后指定。改变数组的数值类型最简单的方法是使用astype方法,以制作具有新类型的原始调整大小副本:^(6)

In [0]: x = np.array([[1, 2, 3], [4, 5, 6]], np.int32) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
In [1]: x.shape
Out[1]: (2, 3)
In [2]: x.shape = (6,)
In [3]: x
Out[3]: array([1, 2, 3, 4, 5, 6], dtype=int32)
In [4]  x = x.astype('int64')
In [5]: x.dtype
Out[5]: dtype('int64')

1

数组将把一个数字的嵌套列表转换为合适形状的多维形式。

创建数组

除了使用数字列表创建数组外,NumPy 还提供了一些实用函数来创建具有特定形状的数组。zerosones是最常用的函数,用于创建预填充的数组。以下是一些示例。注意,这些方法的默认dtype是 64 位浮点数(float64):

In [32]: a = np.zeros([2,3])
In [33]: a
Out[33]:
array([[ 0.,  0.,  0.],
       [ 0.,  0.,  0.]])

In [34]: a.dtype
Out[34]: dtype('float64')

In [35]: np.ones([2, 3])
Out[35]:
array([[ 1.,  1.,  1.],
       [ 1.,  1.,  1.]])

更快的empty方法只接受一个内存块而没有填充开销,留下初始化由您负责。这意味着与np.zeros不同,您不知道也无法保证数组具有哪些值,因此请谨慎使用:

empty_array = np.empty((2,3)) # create an uninitialized array

empty_array
Out[3]:
array([[  6.93185732e-310,   2.52008024e-316,   4.71690401e-317],
       [  2.38085057e-316,   6.93185752e-310,   6.93185751e-310]])

另一个有用的实用函数是random,它与 NumPy 的random模块中的一些有用的兄弟函数一起找到。这将创建一个形状随机数组:

>>> np.random.random((2,3))
>>> Out:
array([[ 0.97519667,  0.94934859,  0.98379541], ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
       [ 0.10407003,  0.35752882,  0.62971186]])

1

一个 2×3 的随机数数组,在范围 0 <= x < 1 内。

方便的linspace在设置的区间内创建指定数量的均匀间隔样本。arange类似,但使用步长参数:

np.linspace(2, 10, 5) # 5 numbers in range 2-10
Out: array([2., 4.,6., 8., 10.]) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)

np.arange(2, 10, 2) # from 2 to 10 (exlusive) with step-size 2.
Out: array([2, 4, 6, 8])

请注意,与arange不同,linspace包括上限值,并且数组的数据类型是默认的float64

数组索引和切片

一维数组的索引和切片与 Python 列表类似:

a = np.array([1, 2, 3, 4, 5, 6])
a[2] # Out: 3
a[3:5] # Out: array([4, 5])
# every second item from 0-4 set to 0
a[:4:2] = 0 # Out: array([0, 2, 0, 4, 5, 6])
a[::-1] # Out: array([6, 5, 4, 0, 2, 0]), reversed

多维数组的索引与1-D形式类似。每个维度都有自己的索引/切片操作,这些操作在以逗号分隔的元组中指定。^(7) 图 7-1 展示了这是如何工作的。

dpj2 0701

图 7-1. 使用 NumPy 进行多维索引

请注意,如果选择元组中的对象数少于维数的数量,则假定其余维度完全选择(:)。省略号也可以用作所有索引的全选的缩写,扩展为所需数量的:对象。我们将使用一个三维数组来演示:

a = np.arange(8)
a.shape = (2, 2, 2)
a
Out:
array([[[0, 1],
        [2, 3]],

       [[4, 5],
        [6, 7]]])

NumPy 提供了一个方便的 array_equal 方法,可以按形状和元素比较数组。我们可以用它来展示以下数组选择的等价性,获取轴 0 的第二个子数组:

a1 = a[1]
a1
Out:
array([[4, 5],
       [6, 7]])

测试等价性:

np.array_equal(a1, a[1,:])
Out: True

np.array_equal(a1, a[1,:,:])
Out: True
# Taking the first element of the subarrays
# array([[0, 2], [4, 6]])
np.array_equal(a[...,0], a[:,:,0])
Out: True

几个基本操作

NumPy 数组的一个非常酷的功能之一是,您可以像操作普通数值变量一样进行基本(以及不那么基本的)数学运算。图 7-2 展示了如何在二维数组上使用一些重载的算术运算符。简单的数学运算适用于数组的所有成员。请注意,当数组除以浮点值(2.0)时,结果会自动转换为浮点类型(float64)。能够像操作单个数字一样操作数组是 NumPy 的一大优势,也是其表现力的重要组成部分。

dpj2 0702

图 7-2. 在二维 NumPy 数组上进行的几个基本数学操作

布尔运算符的工作方式与算术运算符类似。正如我们将在下一章看到的那样,这是创建 pandas 中经常使用的布尔掩码的非常有用的方式。这里有一个小例子:

a = np.array([45, 65, 76, 32, 99, 22])
a < 50
Out[69]: array([ True, False, False,  True, False,  True]
               , dtype=bool)

数组还有许多有用的方法,其中一部分在 示例 7-1 中进行了演示。您可以在 NumPy 官方文档 中获取全面的介绍。

示例 7-1. 一些数组方法
a = np.arange(8).reshape((2,4))
# array([[0, 1, 2, 3],
#        [4, 5, 6, 7]])
a.min(axis=1)
# array([0, 4])
a.sum(axis=0)
# array([4, 6, 8, 10])
a.mean(axis=1) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
# array([ 1.5, 5.5 ])
a.std(axis=1) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
# array([ 1.11803399,  1.11803399])

1

沿第二轴的平均值。

2

[0, 1, 2, 3] 的标准差,…​

还有大量的内置数组函数。示例 7-2 展示了其中的一些,您可以在 NumPy 官方网站 上找到 NumPy 内置数学函数的全面列表。

示例 7-2. 一些 NumPy 数组数学函数
# Trigonometric functions
pi = np.pi
a = np.array([pi, pi/2, pi/4, pi/6])

np.degrees(a) # radians to degrees
# Out: array([ 180., 90., 45., 30.,])

sin_a = np.sin(a)
# Out: array(  1.22464680e-16,   1.00000000e+00, ![1
#               7.07106781e-01,   5.00000000e-01])
# Rounding
np.round(sin_a, 7) # round to 7 decimal places
# Out: array([ 0.,  1.,  0.7071068,  0.5 ])

# Sums, products, differences
a = np.arange(8).reshape((2,4))
# array([[0, 1, 2, 3],
#        [4, 5, 6, 7]])

np.cumsum(a, axis=1) # cumulative sum along second axis
# array([[ 0,  1,  3,  6],
#        [ 4,  9, 15, 22]])

np.cumsum(a) # without axis argument, array is flattened
# array([ 0,  1,  3,  6, 10, 15, 21, 28])

1

注意 sin(pi) 的浮点舍入误差。

创建数组函数

无论您使用的是 pandas 还是诸如 SciPy、scikit-learn 或 PyTorch 等许多 Python 数据处理库之一,核心数据结构往往是 NumPy 数组。因此,掌握一些小的数组处理函数对您的数据处理工具包和数据可视化工具链来说是一个很好的补充。通常可以通过简短的互联网搜索找到社区解决方案,但自己动手编写代码不仅能够带来满足感,而且是学习的一个好方法。让我们看看如何利用 NumPy 数组来计算一个移动平均。移动平均是基于最近的 n 个值的移动窗口的一系列平均值,其中 n 是可变的,也称为滚动平均

计算移动平均

示例 7-3 展示了在一维 NumPy 数组上计算移动平均所需的几行代码。^(8)正如您所见,这些代码既简洁又清晰,但其中确实包含了相当多的内容。让我们来详细分析一下。

示例 7-3. 使用 NumPy 计算移动平均
def moving_average(a, n=3):
    ret = np.cumsum(a, dtype=float)
    ret[n:] = ret[n:] - ret[:-n]
    return ret[n - 1:] / n

函数接收一个数组 a 和一个指定移动窗口大小 n 的数字。

我们首先使用 NumPy 的内置方法计算数组的累积和:

a = np.arange(6)
# array([0, 1, 2, 3, 4, 5])
csum = np.cumsum(a)
csum
# Out: array([0, 1, 3, 6, 10, 15])

从累积和数组的第 n 个索引开始,我们对所有 i 减去 in 的值,这意味着 i 现在具有包括 a 的最后 n 个值的总和。以下是一个窗口大小为三的示例:

# a = array([0, 1, 2, 3, 4, 5])
# csum = array([0, 1, 3, 6, 10, 15])
csum[3:] = csum[3:] - csum[:-3]
# csum = array([0, 1, 3, 6, 9, 12])

比较数组 a 与最终数组 csum,索引 5 现在是窗口 [3, 4, 5] 的总和。

因为移动平均仅对索引 (n–1) 及之后有意义,所以只需返回这些值,除以窗口大小 n 得到平均值。

moving_average 函数需要一些时间才能理解,但它是使用 NumPy 数组和数组切片能够实现简洁和表达力的一个很好的例子。您也可以轻松地用纯 Python 编写该函数,但对于规模较大的数组来说,它可能会更加复杂,关键是速度更慢。

把函数投入实际运行:

a = np.arange(10)
moving_average(a, 4)
# Out[98]: array([ 1.5,  2.5,  3.5,  4.5,  5.5,  6.5,  7.5])

总结

本章奠定了 NumPy 的基础,重点是其构建块,即 NumPy 数组或 ndarray。精通 NumPy 对于任何与数据相关的 Python 开发者来说都是一项核心技能。它支持大多数 Python 强大的数据处理堆栈,因此仅出于这个原因,您应该熟练掌握其数组操作。

与 NumPy 熟悉会使 pandas 的工作更加轻松,并打开丰富的 NumPy 生态系统,涵盖科学、工程、机器学习和统计算法。尽管 pandas 将其 NumPy 数组隐藏在数据容器背后,如其 DataFrame 和 Series,这些容器被适配为处理异构数据,但这些容器在大多数情况下表现得像 NumPy 数组,并且通常在需要时会表现出正确的行为。了解 ndarray 在其核心的事实,也有助于您在为 pandas 构建问题时考虑到 NumPy 的情况。现在我们已经掌握了其基本构建块,让我们看看 pandas 如何将均匀的 NumPy 数组扩展到异构数据领域,在这里进行大部分数据可视化工作。

^(1) Python 的脚本易用性是以性能的原始速度为代价的。通过封装快速的低级库,像 NumPy 这样的项目旨在实现简单、无冗余的编程和极高的性能。

^(2) NumPy 用于实现一些非常高级的数学功能,因此不要期望能够完全理解在线看到的所有内容—​只需理解其中的基本构建块。

^(3) 这意味着 NumPy 处理的是相同数据类型(dtype)的数组,而不是像 Python 列表那样可以包含字符串、数字、日期等。

^(4) 这假设数组符合形状和类型的约束条件。

^(5) 使用 * 将所有模块变量导入到您的命名空间几乎总是一个坏主意。

^(6) 更节省内存且性能更好的方法涉及操作数组的视图,但确实需要一些额外步骤。参见 这篇 Stack Overflow 文章 获取一些示例以及对其优缺点的讨论。

^(7) 有一种简写的点符号表示法(例如,[..1:3])用于选择所有索引。

^(8) NumPy 有一个 convolve 方法,这是计算简单移动平均的最简单方法,但不够具有指导意义。此外,pandas 也有许多专门的方法来实现这一点。

第八章:pandas 简介

pandas 是我们数据可视化工具链中的关键组成部分,因为我们将使用它来清理和探索我们最近抓取的数据集(参见 第六章)。上一章介绍了 NumPy,这是 Python 的数组处理库,也是 pandas 的基础。在我们应用 pandas 之前,本章将介绍其关键概念,并展示它如何与现有数据文件和数据库表进行交互。接下来的几章将继续在实际工作中学习 pandas。

pandas 为何专为数据可视化定制

无论是基于网络还是印刷的任何数据可视化,很有可能被可视化的数据最初都存储在类似 Excel、CSV 文件或 HDF5 的行列式电子表格中。当然,也有一些可视化方式,如网络图,对于行列式数据不是最佳形式,但它们属于少数。pandas 专为操作行列式数据表而设计,其核心数据类型是 DataFrame,最好将其视为非常快速的编程电子表格。

pandas 的开发动机

由 Wes Kinney 在 2008 年首次公开,pandas 是为解决一个特定问题而构建的——即虽然 Python 在数据操作方面表现出色,但在数据分析和建模方面相对较弱,尤其是与 R 等强大工具相比。

pandas 设计用于处理类似行列式电子表格中发现的异构^(1)数据,但巧妙地利用了 NumPy 的同质数值数组的一些速度优势,这些数组被数学家、物理学家、计算机图形学等广泛使用。结合 Jupyter 笔记本和 Matplotlib 绘图库(以及像 seaborn 这样的辅助库),pandas 是一款一流的交互式数据分析工具。作为 NumPy 生态系统的一部分,它的数据建模可以轻松地通过诸如 SciPy、statsmodels 和 scikit-learn 等库进行增强。

数据与测量的分类

在接下来的章节中,我将介绍 pandas 的核心概念,重点讨论 DataFrame 以及如何通过常见的数据存储方式(如 CSV 文件和 SQL 数据库)将数据导入和导出。但首先,让我们稍作偏离,思考一下 pandas 设计用来处理的异构数据集的真正含义,这也是数据可视化的重要基础。

有可能会有一些可视化,比如柱状图或线图用来说明文章或现代网络仪表板中测量结果的变化,商品价格随时间的变化,一年中降雨量的变化,不同族裔的投票意向等。这些测量结果大致可以分为两组,数值型和分类型。数值型可以分为区间和比率尺度,分类值则可以进一步分为名义和有序测量。这样数据可视化者可以得到四种广泛的观察类别。

让我们以一组推文为例,以便提取这些测量类别。每条推文都有各种数据字段:

{
  "text": "#Python and #JavaScript sitting in a tree...", ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
  "id": 2103303030333004303, ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
  "favorited": true, ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
  "filter_level":"medium", ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
  "created_at": "Wed Mar 23 14:07:43 +0000 2015", ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/4.png)
  "retweet_count":23, ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/5.png)
  "coordinates":[-97.5, 45.3] ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/6.png)
  ...
}

1

textid字段是唯一的标识符。前者可能包含分类信息(比如包含#Python 标签的推文类别),而后者可能用于创建一个类别(比如所有转发该推文的用户集合),但它们本身不是可视化字段。

2

favorited是布尔值,分类信息,将推文分为两组。这算作是名义类别,因为可以计数但不能排序。

3

filter_level也是分类信息,但它是有序的。过滤级别有低→中→高的顺序。

4

created_at字段是时间戳,数值尺度上的区间值。我们可能希望按照这个尺度对推文进行排序,这是 pandas 会自动完成的,并且可能会划分为更广泛的间隔,比如按天或者按周。再次强调,pandas 使这变得非常简单。

5

retweet_count同样是数值尺度,但是是比率尺度。比率尺度与区间尺度相反,有一个有意义的零点——在这种情况下是没有转发。而我们的created_at时间戳则可以有一个任意的基线(比如 unix 时间或公历的年份 0),就像温度尺度一样,摄氏度的 0 度等同于开尔文的 273.15 度。

6

coordinates如果有的话,有两个经纬度数值尺度。两者都是区间尺度,虽然讨论角度的比率没有太多意义。

因此,我们的简单推文字段的小子集包含了覆盖所有通常接受的测量分区的异质信息。而 NumPy 数组通常用于同质化的数值计算,pandas 则设计用于处理分类数据、时间序列和反映现实世界数据异质性的项目。这使其非常适合数据可视化。

现在我们知道 pandas 设计用于处理的数据类型,让我们看看它使用的数据结构。

DataFrame

在 pandas 会话中的第一步通常是将一些数据加载到 DataFrame 中。我们将在后面的部分中介绍我们可以做到这一点的各种方法。现在,让我们从文件中读取我们的 nobel_winners.json JSON 数据。read_json 返回一个从指定的 JSON 文件解析的 DataFrame。按照惯例,DataFrame 变量以 df 开头:

import pandas as pd

df = pd.read_json('data/nobel_winners.json')

有了我们的 DataFrame,让我们检查其内容。获取 DataFrame 的行列结构的快速方法是使用其 head 方法显示(默认情况下)前五个项目。图 8-1 显示了来自 Jupyter 笔记本 的输出,突出显示了 DataFrame 的关键元素。

dpj2 0801

图 8-1. pandas DataFrame 的关键元素

索引

DataFrame 的列通过 columns 属性进行索引,这是一个 pandas index 实例。让我们选择 图 8-1 中的列:

In [0]: df.columns
Out[0]: Index(['born_in', 'category', ... ], dtype='object')

最初,pandas 行具有一个单一的数值索引(如果需要,pandas 可以处理多个索引),可以通过 index 属性访问。默认情况下,这是一个节省内存的 RangeIndex

In [1]: df.index
Out[1]: RangeIndex(start=0, stop=1052, step=1)

除了整数外,行索引还可以是字符串、DatetimeIndexPeriodIndex 用于基于时间的数据,等等。通常,为了帮助选择,DataFrame 的一列将通过 set_index 方法设置为索引。在以下代码中,我们首先使用 set_index 方法将我们的 Nobel DataFrame 的索引设置为名称列,然后使用 loc 方法按索引标签选择一行(在本例中为 name):

In [2] df = df.set_index('name') ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
In [3] df.loc['Albert Einstein'] ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
Out[3]:
                born_in category      country date_of_birth date_of_death  \ name
Albert Einstein          Physics  Switzerland    1879-03-14    1955-04-18
Albert Einstein          Physics      Germany    1879-03-14    1955-04-18
[...]

df = df.reset_index() ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)

1

将索引设置为名称列。

2

现在您可以按 name 标签选择一行。

3

将索引返回到原始基于整数的状态。

行和列

DataFrame 的行和列存储为 pandas Series,这是 NumPy 数组的异构对应物。这些本质上是带有标签的一维数组,可以包含从整数、字符串和浮点数到 Python 对象和列表的任何数据类型。

有两种方法可以从 DataFrame 中选择一行。我们已经看到了 loc 方法,它通过标签进行选择。还有一个 iloc 方法,它通过位置进行选择。因此,要选择 图 8-1 中的行,我们获取第二行:

In [4] df.iloc[2]
Out[4]:
name                                              Vladimir Prelog *
born_in                                      Bosnia and Herzegovina
category                                                  Chemistry
country
date_of_birth                                         July 23, 1906
...
year                                                           1975
Name: 2, dtype: object

您可以使用点符号^(2)或传统的关键字字符串数组访问方法获取 DataFrame 的列。这将返回一个 pandas Series,其中包含所有列字段,并保留其 DataFrame 索引:

In [9] gender_col = df.gender # or df['gender']
In [10] type(gender_col)
Out[10] pandas.core.series.Series
In [11] gender_col.head() # grab the Series' first five items
Out[11]:
0    male #index, object
1    male
2    male
3    None
4    male
Name: gender, dtype: object

选择分组

有各种方法可以选择我们 DataFrame 的组(或行子集)。通常我们想要选择具有特定列值的所有行(例如,所有物理学类别的行)。一种方法是使用 DataFrame 的groupby方法对列(或列列表)进行分组,然后使用get_group方法选择所需的组。让我们使用这两种方法选择所有诺贝尔物理学奖获得者:

cat_groups = df.groupby('category')
cat_groups
#Out[-] <pandas.core.groupby.generic.DataFrameGroupBy object ...>

cat_groups.groups.keys()
#Out[-]: dict_keys(['', 'Chemistry', 'Economics', 'Literature',\
#                  'Peace', 'Physics', 'Physiology or Medicine'])
 ...

In [14] phy_group = cat_groups.get_group('Physics')
In [15] phy_group.head()
Out[15]:
                 name born_in category  country    date_of_birth  \
13   François Englert          Physics  Belgium  6 November 1932
19         Niels Bohr          Physics  Denmark   7 October 1885
23  Ben Roy Mottelson          Physics  Denmark     July 9, 1926
24          Aage Bohr          Physics  Denmark     19 June 1922
47     Alfred Kastler          Physics   France       3 May 1902
...

另一种选择行子集的方法是使用布尔掩码来创建新的 DataFrame。你可以像在 NumPy 数组中对所有成员应用布尔运算符一样,对 DataFrame 中的所有行应用布尔运算符:

In [16] df.category == 'Physics'
Out[16]:
0     False
1     False
...
1047   True
...

然后,可以将生成的布尔掩码应用于原始 DataFrame,以选择其行的子集:

In [17]: df[df.category == 'Physics']
Out[17]:
                          name    born_in category    country  \
13            François Englert             Physics    Belgium
19                  Niels Bohr             Physics    Denmark
23           Ben Roy Mottelson             Physics    Denmark
24                   Aage Bohr             Physics    Denmark
...
1047          Brian P. Schmidt             Physics  Australia   ...

在接下来的章节中,我们将介绍更多关于数据选择的例子。现在,让我们看看如何从现有数据创建 DataFrame 以及如何保存我们数据框架操作的结果。

创建和保存 DataFrame

创建 DataFrame 最简单的方法是使用 Python 字典。这也是您可能不经常使用的一种方法,因为您可能会从文件或数据库访问数据。尽管如此,它有其用途。

默认情况下,我们分别指定列,在以下示例中创建三行名称和类别列:

df = pd.DataFrame({
     'name': ['Albert Einstein', 'Marie Curie',\
     'William Faulkner'],
     'category': ['Physics', 'Chemistry', 'Literature']
     })

我们可以使用from_dict方法允许我们使用我们首选的基于记录的对象数组。from_dict有一个orient参数,允许我们指定类似记录的数据,但 pandas 足够智能,可以解析出数据形式:

df = pd.DataFrame.from_dict( ![1
     {'name': 'Albert Einstein', 'category':'Physics'},
     {'name': 'Marie Curie', 'category':'Chemistry'},
     {'name': 'William Faulkner', 'category':'Literature'}
    ])

1

在这里,我们传入一个对象数组,每个对象对应我们 DataFrame 中的一行。

刚才展示的方法产生了一个相同的 DataFrame:

df.head()
Out:
               name    category
0   Albert Einstein     Physics
1       Marie Curie   Chemistry
2  William Faulkner  Literature

正如提到的,您可能不会直接从 Python 容器创建 DataFrame。相反,您可能会使用 pandas 数据读取方法之一。

pandas 拥有令人印象深刻的read_[format]/to_[format]方法,涵盖了大多数可想象的数据加载用例,从 CSV 到二进制 HDF5 再到 SQL 数据库。我们将介绍与数据可视化工作最相关的子集。有关完整列表,请参阅pandas 文档

默认情况下,pandas 会尝试合理地转换加载的数据。convert_axes(尝试将轴转换为适当的dtype)、dtype(猜测数据类型)和convert_dates参数在读取方法中默认都是True。请参阅pandas 文档以获取可用选项的示例,本例是为了将 JSON 文件读入 DataFrame。

让我们首先涵盖基于文件的 DataFrame,然后看看如何与(非)SQL 数据库交互。

JSON

在 pandas 中轻松加载我们首选的 JSON 格式数据:

df = pd.read_json('file.json')

JSON 文件可以采用各种形式,由可选的orient参数指定,其中之一为[split, records, index, columns, values]。我们的标准形式是一个记录数组,会被检测到:

[{"name":"Albert Einstein", "category":"Physics", ...},
{"name":"Marie Curie", "category":"Chemistry", ... } ... ]

JSON 对象的默认格式是 columns,形式如下:

{"name":{"0":"Albert Einstein","1":"Marie Curie" ... },
"category":{"1","Physics","2":"Chemistry" ... }}

正如讨论的那样,对于基于 Web 的可视化工作,特别是 D3,基于记录的 JSON 数组是将行列数据传递给浏览器的最常见方式。

注意

请注意,您需要有效的 JSON 文件才能使用 pandas 工作,因为 read_json 方法和 Python 的 JSON 解析器通常不够宽容,并且异常信息也不够详细。一个常见的 JSON 错误是未将键用双引号括起来,或者在期望双引号的地方使用单引号。后者对于那些来自单双引号可以互换的语言的人来说尤为常见,这也是为什么您永远不应该自己构建 JSON 文档的原因——总是使用官方或备受尊重的库。

有各种方法可以将 DataFrame 存储为 JSON,但最适合与任何数据可视化工作协作的格式是记录数组。这是 D3 数据的最常见形式,也是我建议从 pandas 输出的形式。将 DataFrame 作为记录写入 JSON 只需在 to_json 方法中指定 orient 字段即可:

df = pd.read_json('data.json')
# ... Perform data-cleaning operations
json = df.to_json('data_cleaned.json', orient='records') ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
Out:
[{"name":"Albert Einstein", "category":"Physics", ...},
{"name":"Marie Curie", "category":"Chemistry", ... } ... ]

1

覆盖默认保存以将 JSON 存储为适合数据可视化的记录。

此外,我们还有参数 date_formatepoch 时间戳,iso 表示 ISO8601 等)、double_precisiondefault_handler,用于在对象无法使用 pandas 解析器转换为 JSON 时调用。详见pandas 文档

CSV

符合 pandas 数据表精神的是,它对 CSV 文件的处理足够复杂,可以处理几乎所有可想象的数据。常规的 CSV 文件,也就是大多数情况下,会在没有参数的情况下加载:

# data.csv:
# name,category
# "Albert Einstein",Physics
# "Marie Curie",Chemistry

df = pd.read_csv('data.csv')
df
Out:
              name   category
0  Albert Einstein    Physics
1      Marie Curie  Chemistry

尽管您可能希望所有 CSV 文件都是逗号分隔的,但您经常会发现文件名以 CSV 结尾,但使用分号或竖线 (|) 等不同的分隔符。它们可能还会对包含空格或特殊字符的字符串使用特定的引用方式。在这种情况下,我们可以在读取请求中指定任何非标准元素。我们将使用 Python 方便的 StringIO 模块来模拟从文件中读取:^(5)

from io import StringIO

data = " `Albert Einstein`| Physics \n`Marie Curie`|  Chemistry"

df = pd.read_csv(StringIO(data),
   sep='|', ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
   names=['name', 'category'], ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
   skipinitialspace=True, quotechar="`")

df
Out:
              name   category
0  Albert Einstein   Physics
1      Marie Curie  Chemistry

1

这些字段是以管道符分隔的,而不是默认的逗号分隔。

2

这里我们提供了缺失的列标题。

当保存 CSV 文件时,我们同样具有相同的灵活性,这里将编码设置为 Unicode utf-8

df.to_csv('data.csv', encoding='utf-8')

想要了解 CSV 选项的详细内容,请查阅pandas 文档

Excel 文件

pandas 使用 Python 的 xlrd 模块来读取 Excel 2003 (.xls) 文件,使用 openpyxl 模块来读取 Excel 2007+ (.xlsx) 文件。后者是一个可选依赖项,需要安装:

$ pip install openpyxl

Excel 文档有多个命名工作表,每个工作表都可以传递给 DataFrame。有两种方法将数据表读入 DataFrame 中。第一种方法是创建然后解析ExcelFile对象:

dfs = {}
xls = pd.ExcelFile('data/nobel_winners.xlsx') # load Excel file
dfs['WinnersSheet1'] = xls.parse('WinnersSheet1', na_values=['NA']) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
dfs['WinnersSheet2'] = xls.parse('WinnersSheet2',
    index_col=1, ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
    na_values=['-'], ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
    skiprows=3 ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/4.png)
    )

1

按名称获取一个工作表并保存到一个字典中。

2

指定要用作 DataFrame 行标签的列位置。

3

识别为 NaN 的其他字符串列表。

4

在处理之前要跳过的行数(例如,元数据)。

或者,您可以使用read_excel方法,它是加载多个电子表格的便捷方法:

dfs = pd.read_excel('data/nobel_winners.xlsx', ['WinnersSheet1','WinnersSheet2'],
                      index_col=None, na_values=['NA'])

使用生成的 DataFrame 检查第二个 Excel 表的内容:

In: dfs['WinnersSheet2'].head()
Out:
     category             nationality  year                name    gender
0       Peace                American  1906  Theodore Roosevelt      male
1  Literature           South African  1991     Nadine Gordimer    female
2   Chemistry  Bosnia and Herzegovina  1975     Vladamir Prelog      male

不使用read_excel的唯一原因是如果您需要不同的参数来读取每个 Excel 工作表。

可以使用第二个(sheetname)参数按索引或名称指定工作表。sheetname可以是单个名称字符串或索引(从 0 开始)或混合列表。默认情况下,sheetname0,返回第一个工作表。示例 8-1 显示了一些变化。将sheetname设置为None将返回一个以工作表名称为键的 DataFrame 字典。

示例 8-1. 加载 Excel 工作表
# return the first datasheet
df = pd.read_excel('nobel_winners.xls')

# return a named sheet
df = pd.read_excel('nobel_winners.xls', 'WinnersSheet3')

# first sheet and sheet named 'WinnersSheet3'
df = pd.read_excel('nobel_winners.xls', [0, 'WinnersSheet3'])

# all sheets loaded into a name-keyed dictionary
dfs = pd.read_excel('nobel_winners.xls', sheetname=None)

parse_cols参数允许您选择要解析的工作表列。将parse_cols设置为整数值将选择到该序数的所有列。将parse_cols设置为整数列表允许您选择特定的列:

# parse up to the fifth column
pd.read_excel('nobel_winners.xls', 'WinnersSheet1', parse_cols=4)

# parse the second and fourth columns
pd.read_excel('nobel_winners.xls', 'WinnersSheet1', parse_cols=[1, 3])

有关read_excel的更多信息,请参阅pandas 文档

您可以使用to_excel方法将 DataFrame 保存到 Excel 文件的工作表中,给出 Excel 文件名和工作表名称,本例中分别为 'nobel_winners''WinnersSheet1'

df.to_excel('nobel_winners.xlsx', sheet_name='WinnersSheet1')

有许多与to_csv类似的选项,都在pandas 文档中有详细介绍。因为 pandas Panels 和 Excel 文件可以存储多个 DataFrame,所以有一个Panel to_excel方法可以将其所有的 DataFrame 写入 Excel 文件中。

如果您需要选择要写入共享 Excel 文件的多个 DataFrame,可以使用ExcelWriter对象:

with pd.ExcelWriter('nobel_winners.xlsx') as writer:
    df1.to_excel(writer, sheet_name='WinnersSheet1')
    df2.to_excel(writer, sheet_name='WinnersSheet2')

SQL

根据偏好,pandas 使用 Python 的SQLAlchemy模块来进行数据库抽象化。如果使用SQLAlchemy,还需要数据库的驱动程序库。

使用read_sql方法加载数据库表或 SQL 查询结果是最简单的方法。让我们使用我们首选的 SQLite 数据库,并将其获奖者表读入 DataFrame 中:

import sqlalchemy

engine = sqlalchemy.create_engine(
                'sqlite:///data/nobel_winners.db') ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
df = pd.read_sql('winners', engine) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
df
Out:
     index                category    country date_of_birth
0     4                   Peace    Belgium    1829-07-26
...
                      name                          place_of_birth
0    Auguste Beernaert  Ostend ,  Netherlands  (now  Belgium )
...

1

在这里,我们使用一个现有的 SQLite(基于文件的)数据库。SQLAlchemy 可以为所有常用的数据库创建引擎,例如 mysql://USER:PASSWORD@localhost/db

2

'nobel_winners'SQL 表的内容读入 DataFrame。read_sqlread_sql_tableread_sql_query方法的便捷包装器,根据其第一个参数将执行正确的操作。

将 DataFrame 写入 SQL 数据库非常简单。使用刚刚创建的引擎,我们可以将获奖者表的副本添加到我们的 SQLite 数据库中:

# save DataFrame df to nobel_winners SQL table
df.to_sql('winners_copy', engine, if_exists='replace')

如果遇到由于数据包大小限制而导致的错误,可以使用chunksize参数设置每次写入的行数:

# write 500 rows at a time
df.to_sql('winners_copy', engine, chunksize=500)

pandas 将会尝试将数据映射到适当的 SQL 类型,推断对象的数据类型。如果需要,可以在加载调用中覆盖默认类型:

from sqlalchemy.types import String
df.to_sql('winners_copy', engine, dtype={'year': String}) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)

1

覆盖 pandas 的推断,并指定年份作为String列。

进一步的 pandas-SQL 交互细节可以在pandas 文档中找到。

MongoDB

对于数据可视化工作,像 MongoDB 这样的文档型 NoSQL 数据库非常方便。在 MongoDB 的情况下,情况更好,因为它使用一种名为 BSON 的 JSON 二进制形式作为其数据存储格式,即二进制 JSON。由于 JSON 是我们选择的数据粘合剂,连接我们的 Web 数据可视化和其后端服务器,所以有足够的理由考虑将数据集存储在 Mongo 中。它还与 pandas 很好地协作。

正如我们所见,pandas 的 DataFrame 可以很好地转换到 JSON 格式,并且可以将 Mongo 文档集合轻松转换为 pandas DataFrame:

import pandas as pd
from pymongo import MongoClient

client = MongoClient() ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)

db = client.nobel_prize ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
cursor = db.winners.find() ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
df = pd.DataFrame(list(cursor)) ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/4.png)
df ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/5.png)
# _

1

创建一个 Mongo 客户端,使用默认的主机和端口。

2

获取nobel_prize数据库。

3

winner集合中找到所有文档。

4

将游标中的所有文档加载到列表中,并用于创建 DataFrame。

5

此时 winners 集合为空—让我们用一些 DataFrame 数据填充它。

将 DataFrame 记录插入 MongoDB 数据库同样简单。在这里,我们使用我们在示例 3-5 中定义的get_mongo_database方法获取我们的nobel_prize数据库,并将 DataFrame 保存到其 winners 集合中:

db = get_mongo_database('nobel_prize')

records = df.to_dict('records') ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
db[collection].insert_many(records) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)

1

将 DataFrame 转换为dict,使用records参数将行转换为单独的对象。

2

对于 PyMongo 版本 2,请使用insert方法。

pandas 没有像to_csvread_csv那样的 MongoDB 便利方法,但是很容易编写几个实用函数来在 MongoDB 和 DataFrame 之间进行转换:

def mongo_to_dataframe(db_name, collection, query={},\
                       host='localhost', port=27017,\
                       username=None, password=None,\
                        no_id=True):
    """ create a DataFrame from mongodb collection """

    db = get_mongo_database(db_name, host, port, username,\
     password)
    cursor = db[collection].find(query)
    df =  pd.DataFrame(list(cursor))

    if no_id: ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
        del df['_id']

    return df

def dataframe_to_mongo(df, db_name, collection,\
                       host='localhost', port=27017,\
                       username=None, password=None):
    """ save a DataFrame to mongodb collection """
    db = get_mongo_database(db_name, host, port, username,\
     password)

    records = df.to_dict('records')
    db[collection].insert_many(records)

1

Mongo 的 _id 字段将包含在 DataFrame 中。默认情况下,删除该列。

将 DataFrame 的记录插入 Mongo 后,让我们确保它们已经成功存储:

db = get_mongo_database('nobel_prize')
list(db.winners.find()) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
[{'_id': ObjectId('62fcf2fb0e7fe50ac4393912'),
  'id': 1,
  'category': 'Physics',
  'name': 'Albert Einstein',
  'nationality': 'Swiss',
  'year': 1921,
  'gender': 'male'},
 {'_id': ObjectId('62fcf2fb0e7fe50ac4393913'),
  'id': 2,
  'category': 'Physics',
  'name': 'Paul Dirac',
  'nationality': 'British',
  'year': 1933,
  'gender': 'male'},
 {'_id': ObjectId('62fcf2fb0e7fe50ac4393914'),
  'id': 3,
  'category': 'Chemistry',
  'name': 'Marie Curie',
  'nationality': 'Polish',
  'year': 1911,
  'gender': 'female'}]

1

集合的 find 方法返回一个游标,我们将其转换为 Python 列表以查看内容。

另一种创建 DataFrame 的方法是从一系列 Series 构建它们。让我们来看看这个过程,并借此机会更详细地探讨 Series。

Series 转为 DataFrame

Series 是 pandas 的 DataFrame 的构建模块。它们可以独立地使用与 DataFrame 镜像的方法进行操作,并且它们可以组合成 DataFrame,正如我们将在子节中看到的那样。

pandas Series 的关键思想是索引。这些索引作为标签,用于包含在一行数据中的异构数据。当 pandas 操作多个数据对象时,这些索引用于对齐字段。

Series 可以通过三种方式之一创建。第一种是从 Python 列表或 NumPy 数组创建:

s = pd.Series([1, 2, 3, 4]) # Series(np.arange(4))
Out:
0    1 # index, value
1    2
2    3
3    4
dtype: int64

注意,我们的 Series 自动创建了整数索引。如果我们要向 DataFrame(表)添加一行数据,我们将希望通过将它们作为整数或标签的列表传递来指定列索引:

s = pd.Series([1, 2, 3, 4], index=['a', 'b', 'c', 'd'])
s
Out:
a    1
b    2
c    3
d    4
dtype: int64

注意,索引数组的长度应与数据数组的长度匹配。

我们可以使用 Python dict 指定数据和索引:

s = pd.Series({'a':1, 'b':2, 'c':3})
Out:
a    1
b    2
c    3
dtype: int64

如果我们连同 dict 传递一个索引数组,pandas 将做出明智的事情,将索引与数据数组进行匹配。任何不匹配的索引将设置为 NaN(不是数字),并且任何不匹配的数据将被丢弃。注意,元素少于索引的一个后果是 series 被转换为 float64 类型:

s = pd.Series({'a':1, 'b':2}, index=['a', 'b', 'c'])
Out:
a    1.0
b    2.0
c    NaN
dtype: float64

s = pd.Series({'a':1, 'b':2, 'c':3}, index=['a', 'b'])
Out:
a 1
b 2
dtype: int64

最后,我们可以将单个标量值作为数据传递给 Series,只要我们还指定了一个索引。然后,该标量值将应用于所有索引:

pd.Series(9, {'a', 'b', 'c'})
Out:
a    9
b    9
c    9
dtype: int64

Series 就像 NumPy 数组(ndarray)一样,这意味着它们可以传递给大多数 NumPy 函数:

s = pd.Series([1, 2, 3, 4], ['a', 'b', 'c', 'd'])
np.sqrt(s)
Out:
a    1.000000
b    1.414214
c    1.732051
d    2.000000
dtype: float64

切片操作的工作方式与 Python 列表或 ndarray 相同,但请注意索引标签会被保留:

s[1:3]
Out:
b  2
c  3
dtype: int64

与 NumPy 的数组不同,pandas 的 series 可以接受多种类型的数据。通过添加两个 series 来演示此实用性,其中数字被加在一起,而字符串被串联:

pd.Series([1, 2.1, 'foo']) + pd.Series([2, 3, 'bar'])
Out:
0         3 # 1 + 2
1       5.1 # 2.1 + 3
2    foobar # strings correctly concatenated
dtype: object

在与 NumPy 生态系统交互、操作来自 DataFrame 的数据或在 pandas 的 Matplotlib 封装器之外创建可视化时,创建和操作单独的 Series 尤为重要。

由于 Series 是 DataFrame 的构建模块,因此可以使用 pandas 的 concat 方法将它们连接起来创建 DataFrame:

names = pd.Series(['Albert Einstein', 'Marie Curie'],\
 name='name') ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
categories = pd.Series(['Physics', 'Chemistry'],\
 name='category')

df = pd.concat([names, categories], axis=1) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)

df.head()
Out:
               name    category
0   Albert Einstein     Physics
1       Marie Curie   Chemistry

1

我们使用 namescategories series 为 DataFrame 提供数据和列名(series 的 name 属性)。

2

使用 axis 参数为 1 将两个 Series 连接起来,以指示它们是列。

除了刚刚讨论的从文件和数据库创建 DataFrame 的多种方法之外,现在你应该已经掌握了从 DataFrame 中获取数据的坚实基础。

摘要

本章奠定了接下来两章基于 pandas 的基础。讨论了 pandas 的核心概念——DataFrame、Index 和 Series,我们看到了为什么 pandas 非常适合处理数据可视化者处理的现实世界数据类型,通过允许存储异构数据并添加强大的索引系统来扩展 NumPy ndarray

有了 pandas 的核心数据结构的基础,接下来的几章将向你展示如何使用它们来清理和处理你的诺贝尔奖获得者数据集,扩展你对 pandas 工具包的了解,并演示如何在数据可视化环境中应用它。

现在我们知道如何将数据输入和输出 DataFrame,是时候看看 pandas 可以做什么了。我们将首先了解如何确保你的数据无懈可击,发现并修复诸如重复行、丢失字段和损坏数据等异常。

^(1) 典型电子表格中的列通常具有不同的数据类型(dtypes),如浮点数、日期时间、整数等。

^(2) 只有列名是不带空格的字符串时。

^(3) 如果你遇到问题,可以尝试在JSONLint 的验证器中验证你的数据,以获得更好的反馈。

^(4) D3 支持多种其他数据格式,如分层(树状)数据或节点和链接图格式。这里有一个a tree hierarchy specified in JSON的示例。

^(5) 如果你想感受一下 CSV 或 JSON 解析器的使用,我建议你采用这种方法。这比管理本地文件要方便得多。

第九章:使用 pandas 清洗数据

前两章介绍了 pandas 和 NumPy,它扩展了 Numeric Python 库。具备基本的 pandas 知识,我们现在可以开始我们工具链的清洗阶段,旨在查找并消除我们抓取数据集中的脏数据(参见第六章)。本章还将在工作环境中介绍新方法,扩展你的 pandas 知识。

在第八章中,我们介绍了 pandas 的核心组件:DataFrame,一种可以处理现实世界中多种不同数据类型的程序化电子表格,以及其构建块 Series,是 NumPy 均匀 ndarray 的异构扩展。我们还介绍了如何从不同的数据存储中读取和写入,包括 JSON、CSV 文件、MongoDB 和 SQL 数据库。现在我们将开始展示 pandas 的实际运用,展示如何使用它来清洗肮脏的数据。我将以我们肮脏的诺贝尔奖数据集为例,介绍数据清洗的关键要素。

我会慢慢介绍关键的 pandas 概念,让你在实际工作环境中了解。让我们首先弄清楚为什么数据清洗是数据可视化工作中如此重要的一部分。

洗清肮脏的数据

我认为可以说,大多数进入数据可视化领域的人都低估了他们将花费在使数据呈现可用状态上的时间,通常低估的程度相当大。事实上,要获得干净的数据集,可以轻松转化为酷炫可视化效果,可能会占用你一半以上的时间。野外的数据很少是完美的,往往带有错误的手动数据输入的粘滞痕迹,由于疏忽或解析错误而丢失整个字段和/或混合的日期时间格式。

对于本书,为了提出一个适当的复杂挑战,我们的诺贝尔奖数据集是从维基百科上抓取的,这是一个手动编辑的网站,有相当非正式的指南。在这个意义上,数据肯定是脏的——即使在环境更宽容的情况下,人类也会犯错。但即使是来自例如大型社交媒体网站的官方 API 的数据,也经常存在缺失或不完整的字段,来自对数据模式无数更改的疤痕,故意的错误输入等等。

因此,数据清洗是数据可视化工作的基本组成部分,会占用你本来更愿意做的所有酷炫工作的时间——这是擅长它并释放出那些枯燥时间以进行更有意义追求的一个很好的理由。而擅长数据清洗的一个重要部分就是选择正确的工具集,而这正是 pandas 发挥作用的地方。即使是处理相当大的数据集也是切片和切块的一个很好的方法,^(1) 熟悉它可以节省大量时间。这就是本章的作用所在。

总之,使用 Python 的 Scrapy 库(参见第 6 章)从维基百科爬取诺贝尔奖数据产生了一个 JSON 对象数组:

{
  "category": "Physics",
  "name": "Albert Einstein",
  "gender": "male",
  "place_of_birth": "Ulm ,  Baden-W\u00fcrttemberg ,
 German Empire",
  "date_of_death": "1955-04-18",
  ...
}

本章的任务是在我们在下一章用 pandas 探索之前,尽可能将该数组转换为尽可能干净的数据源。

有许多形式的脏数据,最常见的是:

  • 重复条目/行

  • 缺失字段

  • 行不对齐

  • 损坏的字段

  • 列中混合的数据类型

现在我们将探究我们的诺贝尔奖数据中是否存在这些异常。

首先,我们需要将我们的 JSON 数据加载到 DataFrame 中,就像前一章中所示(参见“创建和保存 DataFrame”)。我们可以直接打开 JSON 数据文件:

import pandas as pd

df = pd.read_json(open('data/nobel_winners_dirty.json'))

现在我们已经将脏数据加载到 DataFrame 中,让我们先来全面了解一下我们拥有的数据。

检查数据

pandas DataFrame 有许多方法和属性,可以快速概述其中包含的数据。最通用的是info,它提供了每列的数据条目数量的整洁摘要:

df.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 1052 entries, 0 to 1051
Data columns (total 12 columns):
 #   Column          Non-Null Count  Dtype
---  ------          --------------  -----
 0   born_in         1052 non-null   object
 1   category        1052 non-null   object
 2   country         1052 non-null   object
 3   date_of_birth   1044 non-null   object
 4   date_of_death   1044 non-null   object
 5   gender          1040 non-null   object
 6   link            1052 non-null   object
 7   name            1052 non-null   object
 8   place_of_birth  1044 non-null   object
 9   place_of_death  1044 non-null   object
 10  text            1052 non-null   object
 11  year            1052 non-null   int64
dtypes: int64(1), object(11)
memory usage: 98.8+ KB

您可以看到某些字段缺少条目。例如,虽然我们的 DataFrame 中有 1,052 行,但只有 1,040 个性别属性。还请注意方便的memory_usage——pandas DataFrames 存储在 RAM 中,因此随着数据集的增大,此数字可以很好地指示我们离机器特定的内存限制有多近。

DataFrame 的describe方法提供了相关列的方便的统计摘要:

df.describe()
Out:
              year
count  1052.000000
mean   1968.729087
std      33.155829
min    1809.000000
25%    1947.000000
50%    1975.000000
75%    1996.000000
max    2014.000000

如您所见,默认情况下只描述数值列。我们已经可以看到数据中的一个错误,最小年份为 1809 年,而第一个诺贝尔奖是在 1901 年颁发的,这是不可能的。

describe接受一个include参数,允许我们指定要评估的列数据类型(dtypes)。除了年份之外,我们的诺贝尔奖数据集中的列都是对象,这是 pandas 的默认、万能的dtype,能够表示任何数字、字符串、日期时间等。示例 9-1 展示了如何获取它们的统计数据。

示例 9-1. 描述 DataFrame
In [140]: df.describe(include=['object']) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
Out[140]:
       born_in  category date_of_birth date_of_death gender  \ count     1052      1052          1044          1044   1040
unique      40         7           853           563      2
top             Physio..    9 May 1947                 male
freq       910       250             4           362    983

                              link             name  \ count                         1052             1052
unique                         893              998
top     http://eg/wiki/...                     Daniel Kahneman
freq                             4                2

          country place_of_birth place_of_death  \ count            1052           1044           1044
unique             59            735            410
top     United States
freq              350             29            409
...

1

include参数是要总结的列数据类型的列表(或单个项目)。

从示例 9-1 的输出中可以得到相当多有用的信息,例如有 59 个独特的国籍,美国是最大的群体,有 350 个。

有趣的一点是,在 1,044 个记录的出生日期中,只有 853 个是唯一的,这可能意味着很多事情。可能某些吉利的日子见证了多位获奖者的诞生,或者,在我们进行数据清理时,更有可能存在重复的获奖者,或者某些日期有误或者只记录了年份。重复的获奖者假设得到了证实,因为在 1,052 个名称计数中,只有 998 个是唯一的。虽然有一些多次获奖者,但这些并不足以解释 54 个重复。

DataFrame 的 headtail 方法提供了另一种快速了解数据的方法。默认情况下,它们显示前五行或后五行,但我们可以通过将整数作为第一个参数传递来设置显示的行数。示例 9-2 显示了在我们的诺贝尔 DataFrame 上使用 head 的结果。

示例 9-2. 对 DataFrame 的前五行进行采样
df.head()
Out:
                  born_in                category   date_of_bi..
0                          Physiology or Medicine  8 October 1..
1  Bosnia and Herzegovina              Literature  9 October 1..![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
2  Bosnia and Herzegovina               Chemistry   July 23, 1..
3                                           Peace             ..
4                                           Peace    26 July 1..

    date_of_death gender                                      ..
0   24 March 2002   male   http://en.wikipedia.org/wiki/C%C3%A..
1   13 March 1975   male            http://en.wikipedia.org/wi..
2      1998-01-07   male       http://en.wikipedia.org/wiki/Vl..![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
3             NaN   None  http://en.wikipedia.org/wiki/Institu..
4  6 October 1912   male  http://en.wikipedia.org/wiki/Auguste..

                              name country  \ 0                   César Milstein   Argentina
1                     Ivo Andric *               ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
2                Vladimir Prelog *
3  Institut de Droit International     Belgium
4                Auguste Beernaert     Belgium

1

这些行在 born_in 字段中有条目,并且姓名旁边有星号标记。

2

date_of_death 字段的时间格式与其他行不同。

示例 9-2 中的前五位获奖者展示了一些有用的信息。首先,我们看到第 1 和第 2 行的姓名被星号标记,并在 born_in 字段中有条目 1。其次,请注意第 2 行的 date_of_death 与其他行有不同的时间格式,而 date_of_birth 字段中既有月-日格式,也有日-月格式 2。这种不一致性是人为编辑数据中的常见问题,特别是日期和时间。稍后我们将看到如何使用 pandas 解决这个问题。

示例 9-1 给出了 born_in 字段的对象计数为 1,052,表明没有空字段,但 head 显示只有第 1 和第 2 行有内容。这表明缺失字段是空字符串或空格,这两者在 pandas 中都算作数据。让我们将它们改为不计数的 NaN,这将更好地解释这些数字。但首先,我们需要对 pandas 数据选择做一个简单的介绍。

索引和 pandas 数据选择

在开始清理数据之前,让我们快速回顾一下基本的 pandas 数据选择,以诺贝尔奖数据集为例。

pandas 按行和列进行索引。通常列索引由数据文件、SQL 表等指定,但正如上一章所示,我们可以通过使用 names 参数在创建 DataFrame 时设置或覆盖这些列名。列索引可以作为 DataFrame 的属性访问:

# Our Nobel dataset's columns
df.columns
Out: Index(['born_in', 'category', 'date_of_birth',
...
        'place_of_death', 'text', 'year'], dtype='object')

默认情况下,pandas 为行指定零基整数索引,但我们可以通过在创建 DataFrame 时传递列表或直接设置index属性来覆盖这一点。我们更经常想要将一个或多个 DataFrame 的列用作索引。我们可以使用set_index方法来做到这一点。如果要返回默认索引,可以使用reset_index方法,如示例 9-3 所示。

示例 9-3. 设置 DataFrame 的索引
# set the name field as index
df = df.set_index('name') ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
df.head(2)
Out:
                               born_in                category  \ name ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
César Milstein                          Physiology or Medicine
Ivo Andric *    Bosnia and Herzegovina              Literature
...

df.reset_index(inplace=True) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)

df.head(2)
Out:
             name                 born_in                category  \ 0  César Milstein                          Physiology or Medicine  ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/4.png)
1    Ivo Andric *  Bosnia and Herzegovina              Literature

1

将框架的索引设置为其名称列。将结果返回给df

2

现在行已经按名称索引。

3

将索引重置为整数。请注意,这次我们直接在原地更改它。

4

现在索引是按整数位置。

注意

有两种方法可以更改 pandas DataFrame 或 Series:通过直接更改数据或通过赋值副本。不能保证直接更改更快,而且方法链需要操作返回一个已更改的对象。通常情况下,我使用df = df.foo(...)形式,但大多数变异方法都有一个inplace参数df.foo(..., inplace=True)

现在我们了解了行列索引系统,让我们开始选择 DataFrame 的切片。

我们可以通过点表示法(名称中没有空格或特殊字符)或方括号表示法选择 DataFrame 的列。让我们看看born_in列:

bi_col = df.born_in # or bi = df['born_in']
bi_col
Out:
0
1     Bosnia and Herzegovina
2     Bosnia and Herzegovina
3
...
1051
Name: born_in, Length: 1052, dtype: object

type(bi_col)
Out: pandas.core.series.Series

注意列选择返回一个 pandas Series,保留了 DataFrame 的索引。

DataFrames 和 Series 共享相同的方法来访问行/成员。iloc通过整数位置选择,loc通过标签选择。让我们使用iloc来获取我们 DataFrame 的第一行:

# access the first row
df.iloc[0]
Out:
name                                 César Milstein
born_in
category                             Physiology or Medicine
...

# set the index to 'name' and access by name-label
df.set_index('name', inplace=True)
df.loc['Albert Einstein']
Out:
                 born_in category      country  ...
name
Albert Einstein           Physics  Switzerland  ...
Albert Einstein           Physics      Germany  ...
...

选择多行

可以使用标准的 Python 数组切片与 DataFrame 一起选择多行:

# select the first 10 rows
df[0:10]
Out:
                  born_in                category   date_of_b..
0                          Physiology or Medicine  8 October ..
1  Bosnia and Herzegovina              Literature  9 October ..
...
9                                           Peace      1910-0..
# select the last four rows
df[-4:]
Out:
     born_in                category      date_of_birth date_..
1048                           Peace   November 1, 1878   May..
1049          Physiology or Medicine         1887-04-10    19..
1050                       Chemistry           1906-9-6     1..
1051                           Peace  November 26, 1931      ..

根据条件表达式选择多行的标准方法(例如,列value的值是否大于x)是创建一个布尔掩码并在选择器中使用它。让我们找出所有 2000 年后的诺贝尔奖获得者。首先,我们通过对每一行执行布尔表达式来创建一个掩码:

mask = df.year > 2000 ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
mask
Out:
0     False
1     False
...
13     True
...
1047     True
1048    False
...
Name: year, Length: 1052, dtype: bool

1

所有year字段大于 2000 的行为True

结果布尔掩码共享我们 DataFrame 的索引,可用于选择所有True行:

mask = df.year > 2000
winners_since_2000 = df[mask] ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
winners_since_2000.count()
Out:
...
year              202 # number of winners since 2000
dtype: int64

winners_since_2000.head()
Out:
...
                                               text  year
13                 François Englert , Physics, 2013  2013
32      Christopher A. Pissarides , Economics, 2010  2010
66                         Kofi Annan , Peace, 2001  2001
87               Riccardo Giacconi *, Physics, 2002  2002
88   Mario Capecchi *, Physiology or Medicine, 2007  2007

1

这将返回一个 DataFrame,其中仅包含布尔mask数组为True的行。

布尔遮罩是一种非常强大的技术,能够选择您需要的数据的任何子集。我建议设定一些目标来练习构建正确的布尔表达式。通常,我们会省略中间遮罩的创建。

winners_since_2000 = df[df.year > 2000]

现在我们可以通过切片或使用布尔遮罩来选择单个和多个行,接下来的几节我们将看到如何改变我们的 DataFrame,在此过程中清除脏数据。

清理数据

现在我们知道如何访问我们的数据了,让我们看看如何改进它,从我们在 示例 9-2 中看到的看起来为空的 born_in 字段开始。如果我们查看 born_in 列的计数,它不会显示任何缺失的行,如果有任何字段缺失或 NaN(非数字)的话,它会显示出来:

In [0]: df.born_in.describe()
Out[0]:
count     1052
unique      40
top
freq       910
Name: born_in, dtype: object

查找混合类型

请注意,pandas 使用 dtype 对象存储所有类似字符串的数据。粗略检查表明,该列是空和国家名称字符串的混合。我们可以通过将 Python 的 type 函数 映射到所有成员,然后使用 apply 方法并将结果列表设置为列成员类型的集合来快速检查所有列成员是否都是字符串:

In [1]: set(df.born_in.apply(type))
Out[1]: {str}

这表明 born_in 列的所有成员都是字符串类型 str。现在让我们用一个空字段来替换任何空字符串。

替换字符串

我们想用 NaN 替换这些空字符串,以防止它们被计数。[³] pandas 的 replace 方法是专门为此而设计的,可以应用于整个 DataFrame 或单个 Series:

import numpy as np

bi_col.replace('', np.nan, inplace=True)
bi_col
Out:
0                        NaN ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
1     Bosnia and Herzegovina
2     Bosnia and Herzegovina
3                        NaN
...

bi_col.count()
Out: 142 ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)

1

我们的空 '' 字符串已被替换为 NumPy 的 NaN

2

不像空字符串,NaN 字段是不计算在内的。

在用 NaN 替换空字符串后,我们得到了 born_in 字段的真实计数为 142。

让我们用折扣的 NaN 替换我们 DataFrame 中的所有空字符串:

df.replace('', np.nan, inplace=True)

pandas 允许对列中的字符串(以及其他对象)进行复杂的替换(例如,允许您编写 正则表达式或 regexes,这些表达式适用于整个 Series,通常是 DataFrame 的列)。让我们看一个小例子,使用我们的诺贝尔奖 DataFrame 中用星号标记的姓名。

示例 9-2 显示,我们的一些诺贝尔奖获得者的名字被标记为星号,表示这些获奖者是按出生国家记录的,而不是获奖时的国家:

df.head()
Out:
...
                              name country  \
0                   César Milstein   Argentina
1                     Ivo Andric *
2                Vladimir Prelog *
3  Institut de Droit International     Belgium
4                Auguste Beernaert     Belgium

让我们设置一个任务,通过去除星号并去除任何剩余的空白来清理这些名字。

pandas Series 有一个方便的 str 成员,提供了许多有用的字符串方法,可在数组上执行。让我们使用它来检查有多少带星号的名字:

df[df.name.str.contains(r'\*')]['name'] ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
Out:
1              Ivo Andric *
2         Vladimir Prelog *
...
1041       John Warcup Cornforth *
1046      Elizabeth H. Blackburn *
Name: name, Length: 142, dtype: object ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)

1

我们在name列上使用strcontains方法。请注意,我们必须转义星号('\*'),因为这是一个正则表达式字符串。然后将布尔掩码应用于我们的诺贝尔奖 DataFrame,并列出结果名称。

2

我们的 1,052 行中,有 142 行的名称中包含*

为了清理名称,让我们用空字符串替换星号,并从结果名称中去掉任何空格:

df.name = df.name.str.replace('*', '', regex=True) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
# strip the whitespace from the names
df.name = df.name.str.strip()

1

删除名称字段中的所有星号,并将结果返回到 DataFrame。请注意,我们必须显式地将regex标志设置为True

快速检查显示现在名称已经干净:

df[df.name.str.contains('\*')]
Out:
Empty DataFrame

pandas Series 有大量的字符串处理函数,使您能够搜索和调整字符串列。您可以在API 文档中找到完整列表。

删除行

总结一下,142 位出生地字段为获奖者是重复的,在维基百科传记页上既有出生国家,又有获奖时的国家。尽管前者可能形成有趣的可视化基础,^(4) 但对于我们的可视化,我们希望每个个人奖只代表一次,因此需要从我们的 DataFrame 中删除这些内容。

我们想创建一个新的 DataFrame,只包含那些具有NaNborn_in字段的行。您可能天真地认为将条件表达式与born_in字段与NaN进行比较会在这里起作用,但根据定义^(5),NaN布尔比较始终返回False

np.nan == np.nan
Out: False

因此,pandas 提供了专门的isnull方法来检查折扣(空)字段:

df = df[df.born_in.isnull()] ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
df.count()
Out:
born_in             0 # all entries now empty
category          910
...
dtype: int64

1

isnull 生成一个布尔掩码,对于所有具有空born_in字段的行返回True

born_in列现在不再有用,所以暂时删除它:^(6)

df = df.drop('born_in', axis=1) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)

1

drop 接受一个单个标签或索引(或相同的列表)作为第一个参数,并且一个axis参数以指示行(0和默认)或列(1)索引。

查找重复项

现在,快速的互联网搜索显示截至 2015 年有 889 个人和组织获得了诺贝尔奖。还剩下 910 行,我们仍然有一些重复或异常需要处理。

pandas 提供了一个方便的duplicated方法用于查找匹配的行。这可以根据列名或列名列表进行匹配。让我们获取所有名称重复的列表:

dupes_by_name = df[df.duplicated('name')] ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
dupes_by_name.count()
Out:
...
year              46
dtype: int64

1

duplicated 返回一个布尔数组,对于具有相同name字段的任何行的第一个出现,返回True

现在,有些人曾多次获得诺贝尔奖,但不是 46 次,这意味着有 40 多个获奖者是重复的。考虑到我们抓取的维基百科页面按国家列出了获奖者,最有可能的情况是获奖者被多个国家“认领”。

让我们看一些在我们的诺贝尔奖 DataFrame 中通过名称查找重复项的方法。其中一些方法效率很低,但这是演示一些 pandas 函数的好方法。

默认情况下,duplicated 指示(布尔值 True)第一次出现后的所有重复项,但将 keep 选项设置为 *last* 将重复行的第一次出现设置为 True。通过使用布尔 or (|) 结合这两个调用,我们可以得到完整的重复列表:

all_dupes = df[df.duplicated('name')\
               | df.duplicated('name', keep='last')]
all_dupes.count()
Out:
...
year              92
dtype: int64

我们还可以通过测试我们的 DataFrame 行中是否有一个名称在重复名称列表中来获取所有重复项。pandas 提供了一个方便的 isin 方法来实现这一点:

all_dupes = df[df.name.isin(dupes_by_name.name)] ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
all_dupes.count()
Out:
...
year              92
dtype: int64

1

dupes_by_name.name 是一个包含所有重复名称的列 Series。

我们还可以使用 pandas 强大的 groupby 方法找到所有重复项,该方法通过列或列列表对我们的 DataFrame 行进行分组。它返回一系列键值对,键是列值,值是行的列表:

for name, rows in df.groupby('name'): ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
    print('name: %s, number of rows: %d'%(name, len(rows)))

name: A. Michael Spence, number of rows: 1
name: Aage Bohr, number of rows: 1
name: Aaron Ciechanover, number of rows: 1
...

1

groupby 返回一个 (组名,组) 元组的迭代器。

要获得所有重复的行,我们只需检查由 key 返回的行列表的长度。任何大于一的值都具有名称重复。在这里,我们使用 pandas 的 concat 方法,它接受一个行列表的列表,并创建一个包含所有重复行的 DataFrame。使用 Python 列表构造函数来过滤具有多行的组:

pd.concat(g for _,g in df.groupby('name')\ ![1
                     if len(g) > 1])['name']

Out:
121           Aaron Klug
131           Aaron Klug
615      Albert Einstein
844      Albert Einstein
...
489      Yoichiro Nambu
773      Yoichiro Nambu
Name: name, Length: 92, dtype: object

1

通过筛选具有多行的 name 行组(即重复的名称)来创建一个 Python 列表。

达到相同目标的不同途径

在像 pandas 这样的大型库中,通常有多种方法可以实现相同的目标。对于像我们的诺贝尔奖获得者这样的小型数据集,任何一种方法都可以,但对于大型数据集来说,可能会有显著的性能影响。仅仅因为 pandas 能够执行你要求的操作,并不意味着它一定是高效的。由于幕后进行了大量复杂的数据处理,因此最好做好准备,对低效的方法保持灵活和警惕。

数据排序

现在我们有了 all_dupes DataFrame,其中包含了所有重复的行,按名称排序,让我们用它来演示 pandas 的 sort 方法。

pandas 为 DataFrame 和 Series 类提供了一个复杂的 sort 方法,能够在多个列名上进行排序:

df2 = pd.DataFrame(\
     {'name':['zak', 'alice', 'bob', 'mike', 'bob', 'bob'],\
      'score':[4, 3, 5, 2, 3, 7]})
df2.sort_values(['name', 'score'],\ ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
        ascending=[1,0]) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)

Out:
    name  score
1  alice      3
5    bob      7
2    bob      5
4    bob      3
3   mike      2
0    zak      4

1

首先按名称排序 DataFrame,然后在这些子组内按分数排序。旧版本的 pandas 使用 sort,现在已不推荐使用。

2

将姓名按字母顺序升序排序;将分数从高到低排序。

让我们按姓名对all_dupes的 DataFrame 进行排序,然后查看姓名、国家和年份列:

In [306]: all_dupes.sort_values('name')\
  [['name', 'country', 'year']]
Out[306]:
                          name         country  year
121                 Aaron Klug    South Africa  1982
131                 Aaron Klug  United Kingdom  1982
844            Albert Einstein         Germany  1921
615            Albert Einstein     Switzerland  1921
...
910                Marie Curie          France  1903
919                Marie Curie          France  1911
706     Marie Skłodowska-Curie          Poland  1903
709     Marie Skłodowska-Curie          Poland  1911
...
650              Ragnar Granit          Sweden  1967
960              Ragnar Granit         Finland  1809
...
396              Sidney Altman   United States  1990
995              Sidney Altman          Canada  1989
...
[92 rows x 3 columns]

此输出显示,如预期的那样,一些获奖者因同一年份而分配了两次,但具有不同的国家。它还显示了一些其他异常情况。尽管玛丽·居里确实两次获得诺贝尔奖,但她在这里既有法国国籍又有波兰国籍。^(7)在这里最公平的做法是将战利品分给波兰和法国,并最终选择单一的复合姓。我们还发现了我们 960 行处的 1809 年的异常年份。Sidney Altman 既重复了,又错误地获得了 1990 年的年份。

移除重复项

让我们开始移除我们刚刚识别的重复项,并开始编译一个小的清理函数。

提示

如果您知道数据集是稳定的并且不预期再次运行任何清理脚本,则可以通过数字索引更改行。但是,就像我们抓取的诺贝尔奖数据一样,如果您希望在更新的数据集上运行相同的清理脚本,最好使用稳定的指示器(即,获取玛丽·居里和 1911 年的行,而不是索引 919)。

更改特定行的国家的更健壮的方法是使用稳定的列值来选择行,而不是其索引。这意味着如果索引值更改,则清理脚本仍然可以工作。因此,要将玛丽·居里 1911 年的奖项国家更改为法国,我们可以使用带有loc方法的布尔掩码来选择一行,然后将其国家列设置为法国。请注意,我们为波兰的ł指定了 Unicode:

df.loc[(df.name == 'Marie Sk\u0142odowska-Curie') &\
      (df.year == 1911), 'country'] = 'France'

除了更改玛丽·居里的国家外,我们还希望根据列值从 DataFrame 中删除或删除一些行。我们可以通过两种方法实现这一点,首先是使用 DataFrame 的drop方法,该方法接受一个索引标签列表,或者通过创建一个布尔掩码的新 DataFrame 来筛选我们想要删除的行。如果使用drop,我们可以使用inplace参数来更改现有的 DataFrame。

在以下代码中,我们通过创建一个只包含我们想要的单行的 DataFrame 并将该索引传递给drop方法来删除我们的重复的 Sidney Altman 行,并在原地更改 DataFrame:

df.drop(df[(df.name == 'Sidney Altman') &\
 (df.year == 1990)].index,
    inplace=True)

删除行的另一种方法是使用相同的布尔掩码与逻辑(~)来创建一个新的 DataFrame,该 DataFrame 除了我们选择的行之外的所有行:

df = df[~((df.name == 'Sidney Altman') & (df.year == 1990))]

让我们将此更改和所有当前修改添加到clean_data方法中:

def clean_data(df):
    df = df.replace('', np.nan)
    df = df[df.born_in.isnull()]
    df = df.drop('born_in', axis=1)
    df.drop(df[df.year == 1809].index, inplace=True)
    df = df[~(df.name == 'Marie Curie')]
    df.loc[(df.name == 'Marie Sk\u0142odowska-Curie') &\
           (df.year == 1911), 'country'] = 'France'
    df = df[~((df.name == 'Sidney Altman') &\
     (df.year == 1990))]
    return df

现在我们有了有效的重复项(那些少数多次获奖的诺贝尔奖获得者)和那些具有双重国家的重复项。为了我们可视化的目的,我们希望每个奖项只计数一次,因此我们必须丢弃一半的双重国家奖项。最简单的方法是使用 duplicated 方法,但由于我们按国家字母顺序收集了获奖者,这将偏袒字母顺序较早的国家。除了进行大量研究和辩论之外,看起来最公平的方法似乎是随机选择一个并丢弃它。有各种方法可以做到这一点,但最简单的方法是在使用 drop_duplicates 之前随机化行的顺序,这是一个 pandas 方法,它会在遇到第一个重复项之后删除所有重复的行,或者在设置 take_last=True 参数时删除所有最后一个重复项之前的行。

NumPy 在其 random 模块中有许多非常有用的方法,其中 permutation 对于随机化行索引非常合适。该方法接受一个值数组(或 pandas 索引)并对其进行洗牌。然后我们可以使用 DataFrame 的 reindex 方法应用洗牌后的结果。请注意,我们丢弃了那些共享名称和年份的行,这将保留具有不同年份的合法双重获奖者:

df = df.reindex(np.random.permutation(df.index)) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
df = df.drop_duplicates(['name', 'year'])        ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
df = df.sort_index()                             ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
df.count()
Out:
...
year              865
dtype: int64

1

创建 df 索引的洗牌版本,并用它重新索引 df

2

删除所有共享名称和年份的重复项。

3

将索引返回到按整数位置排序。

如果我们的数据整理成功,应该只剩下有效的重复项,那些著名的双重获奖者。让我们列出剩余的重复项以进行检查:

In : df[df.duplicated('name') |
             df.duplicated('name', keep='last')]\ ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
             .sort_values(by='name')\
             [['name', 'country', 'year', 'category']]
Out:
                       name     country  year   category
548        Frederick Sanger  United Kingdom  1958  Chemistry
580        Frederick Sanger  United Kingdom  1980  Chemistry
292            John Bardeen   United States  1956    Physics
326            John Bardeen   United States  1972    Physics
285        Linus C. Pauling   United States  1954  Chemistry
309        Linus C. Pauling   United States  1962      Peace
706  Marie Skłodowska-Curie          Poland  1903    Physics
709  Marie Skłodowska-Curie          France  1911  Chemistry

1

我们将第一个和最后一个的重复项合并起来以获取它们所有的内容。如果使用较旧版本的 pandas,可能需要使用参数 take_last=True

快速查阅互联网显示我们有正确的四位双重获奖者。

假设我们已经捕捉到了不需要的重复项^(10),让我们继续处理数据的其他“脏”方面。

处理缺失字段

让我们统计一下 DataFrame 中的null字段情况:

df.count()
Out:
category          864 ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
country           865
date_of_birth     857
date_of_death     566
gender            857 ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
link              865
name              865
place_of_birth    831
place_of_death    524
text              865
year              865
dtype: int64

1

缺失了一个类别字段。

2

缺失了八个性别字段。

看起来我们缺少一个 category 字段,这表明存在数据输入错误。如果记得,在抓取我们的诺贝尔奖数据时,我们检查了类别是否与有效列表匹配(参见 示例 6-3)。其中一个似乎未通过此检查。让我们找出是哪一个,方法是获取类别字段为空的行,并显示其名称和文本列:

df[df.category.isnull()][['name', 'text']]
Out:
              name                            text
922  Alexis Carrel  Alexis Carrel , Medicine, 1912

我们为获奖者保存了原始链接文本,您可以看到,亚历克西斯·卡雷尔被列为赢得医学诺贝尔奖,而实际上应该是生理学或医学。让我们现在更正一下:

...
df.loc[df.name == 'Alexis Carrel', 'category'] =\
 'Physiology or Medicine'

我们还缺少 8 位获奖者的gender。让我们列出它们:

df[df.gender.isnull()]['name']
Out:
3                         Institut de Droit International
156                               Friends Service Council
267     American Friends Service Committee  (The Quakers)
574                                 Amnesty International
650                                         Ragnar Granit
947                              Médecins Sans Frontières
1000     Pugwash Conferences on Science and World Affairs
1033                   International Atomic Energy Agency
Name: name, dtype: object

除了拉格纳·格拉尼特(Ragnar Granit)外,所有这些都是没有性别的(缺失人员数据)机构。我们的可视化重点是个人获奖者,因此我们将删除这些,同时确定拉格纳·格拉尼特的性别^(11):

...
def clean_data(df):
...
    df.loc[df.name == 'Ragnar Granit', 'gender'] = 'male'
    df = df[df.gender.notnull()] # remove genderless entries

看看这些变化把我们带到哪里,通过对我们的 DataFrame 执行另一个计数来查看:

df.count()
Out:
category          858
date_of_birth     857 # missing field
...
year              858
dtype: int64

删除了所有机构后,所有条目至少应该有一个出生日期。让我们找到缺失的条目并修复它:

df[df.date_of_birth.isnull()]['name']
Out:
782    Hiroshi Amano
Name: name, dtype: object

可能是因为天野浩是最近(2014 年)的获奖者,他的出生日期无法被抓取到。通过快速的网络搜索,我们确认了天野浩的出生日期,然后手动将其添加到 DataFrame 中:

...
    df.loc[df.name == 'Hiroshi Amano', 'date_of_birth'] =\
    '11 September 1960'

现在我们有 858 位个人获奖者。让我们进行最后一次计数,看看我们的情况如何:

df.count()
Out:
category          858
country           858
date_of_birth     858
date_of_death     566
gender            858
link              858
name              858
place_of_birth    831
place_of_death    524
text              858
year              858
dtype: int64

categorydate_of_birthgendercountryyear的关键字段都填满了,剩余统计数据也有足够的数据。总的来说,有足够干净的数据来形成丰富的可视化基础。

现在让我们通过使我们的时间字段更可用来做出最后的修改。

处理时间和日期

目前,date_of_birthdate_of_death字段由字符串表示。正如我们所见,维基百科的非正式编辑指南导致了许多不同的时间格式。我们的原始 DataFrame 在前 10 个条目中显示了令人印象深刻的多种格式:

df[['name', 'date_of_birth']]
Out[14]:
                       name      date_of_birth
4         Auguste Beernaert       26 July 1829
                                        ...
8         Corneille Heymans      28 March 1892
...                     ...                ...
1047       Brian P. Schmidt  February 24, 1967
1048  Carlos Saavedra Lamas   November 1, 1878
1049       Bernardo Houssay         1887-04-10
1050   Luis Federico Leloir           1906-9-6
1051  Adolfo Pérez Esquivel  November 26, 1931

[858 rows x 2 columns]

为了比较日期字段(例如,从生日减去奖项年份以得到获奖者的年龄),我们需要将它们转换为允许这样的操作的格式。毫不奇怪,pandas 擅长解析混乱的日期和时间,将它们默认转换为 NumPy 的datetime64对象,该对象具有一系列有用的方法和运算符。

将时间列转换为datetime64,我们使用 pandas 的to_datetime方法:

pd.to_datetime(df.date_of_birth, errors='raise') ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
Out:
4      1829-07-26
5      1862-08-29
         ...
1050   1906-09-06
1051   1931-11-26
Name: date_of_birth, Length: 858, dtype: datetime64[ns]

1](#co_cleaning_data_with_pandas_CO20-1)

errors默认值是ignore,但是我们希望它们被标记。

默认情况下,to_datetime会忽略错误,但是在这里,我们希望知道 pandas 是否无法解析date_of_birth,从而给我们手动修复的机会。幸运的是,转换顺利通过,没有错误。

让我们在继续之前修复我们的 DataFrame 的date_of_birth列:

In: df.date_of_birth = pd.to_datetime(df.date_of_birth, errors='coerce')

date_of_birth字段上运行to_datetime引发了一个ValueError,而且一个毫无帮助的错误,没有指示触发它的条目:

In [143]: pd.to_datetime(df.date_of_death, errors='raise')
--------------------------------------------------------------
ValueError                   Traceback (most recent call last)
...
    301     if arg is None:

ValueError: month must be in 1..12

一种找到错误日期的天真方法是遍历我们的数据行,并捕获和显示任何错误。 pandas 有一个方便的iterrows方法提供了一个行迭代器。结合 Python 的try-except块,这成功地找到了我们的问题日期字段:

for i,row in df.iterrows():
    try:
        pd.to_datetime(row.date_of_death, errors='raise') ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
    except:
        print(f"{row.date_of_death.ljust(30)}({row['name']}, {i})") ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)

1](#co_cleaning_data_with_pandas_CO21-1)

在单独的行上运行to_datetime并捕获任何错误。

2

我们将死亡日期左对齐到 30 宽度的文本列,以使输出更易于阅读。pandas 行具有遮罩Name属性,因此我们使用字符串键访问['name']

这列出了有问题的行:

1968-23-07              (Henry Hallett Dale, 150)
May 30, 2011 (aged 89)  (Rosalyn Yalow, 349)
living                  (David Trimble, 581)
Diederik Korteweg       (Johannes Diderik van der Waals, 746)
living                  (Shirin Ebadi, 809)
living                  (Rigoberta Menchú, 833)
1 February 1976, age 74 (Werner Karl Heisenberg, 858)

这是协作编辑时可能遇到的数据错误的良好示例。

虽然最后一种方法有效,但是每当您发现自己在遍历 pandas DataFrame 的行时,您应该停顿一秒钟,并尝试找到更好的方法,利用 pandas 效率的多行数组处理是一个基本的方面。

查找错误日期的更好方法利用了 pandas 的to_datetime方法的coerce参数,如果设为True,则会将任何日期异常转换为NaT(不是时间),这是NaN的时间等价物。然后我们可以基于结果 DataFrame 创建一个布尔掩码,根据NaT日期行生成 Figure 9-1:

with_death_dates = df[df.date_of_death.notnull()] ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
bad_dates = pd.isnull(pd.to_datetime(\
            with_death_dates.date_of_death, errors='coerce')) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
with_death_dates[bad_dates][['category', 'date_of_death',\ 'name']]

1

获取所有具有非空日期字段的行。

2

通过检查with_death_dates中的所有错误日期,创建一个布尔掩码,如果转换失败,则检查空值(NaT)。对于较旧的 pandas 版本,您可能需要使用coerce=True

dpj2 0901

图 9-1. 无法解析的日期字段

取决于您想要多么一丝不苟,这些可以手动更正或强制转换为 NumPy 的时间等效物NaNNaT。我们有超过 500 个有效的死亡日期,足以获得一些有趣的时间统计数据,因此我们将再次运行to_datetime并强制错误为空:

df.date_of_death = pd.to_datetime(df.date_of_death,\
errors='coerce')

现在我们的时间字段已经以可用的格式存在,让我们添加一个字段,用于获得诺贝尔奖时获奖者的年龄。为了获取新日期的年份值,我们需要告诉 pandas 它正在处理一个日期列,使用DatetimeIndex方法。请注意,这给出了奖项年龄的粗略估计,可能会有一年的偏差。对于下一章的数据可视化探索目的,这已经足够了。

df['award_age'] = df.year - pd.DatetimeIndex(df.date_of_birth)\ .year ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)

1

将列转换为DatetimeIndex,一个datetime64数据的ndarray,并使用year属性。

让我们使用新的award_age字段来查看诺贝尔奖最年轻的获奖者:

# use +sort+ for older pandas
df.sort_values('award_age').iloc[:10]\
        [['name', 'award_age', 'category', 'year']]
Out:
                      name  award_age         category  year
725        Malala Yousafzai       17.0            Peace  2014 ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
525  William Lawrence Bragg       25.0          Physics  1915
626    Georges J. F. Köhler       30.0  Phys...Medicine  1976
294           Tsung-Dao Lee       31.0          Physics  1957
858  Werner Karl Heisenberg       31.0          Physics  1932
247           Carl Anderson       31.0          Physics  1936
146              Paul Dirac       31.0          Physics  1933
877        Rudolf Mössbauer       32.0          Physics  1961
226         Tawakkol Karman       32.0            Peace  2011
804        Mairéad Corrigan       32.0            Peace  1976

1

对于女性教育活动,我建议更多地了解玛拉拉的励志故事

现在我们将我们的日期字段格式化,让我们来看看完整的clean_data函数,它总结了本章的清理工作。

完整的clean_data函数

对于像从维基百科抓取的数据集这样手动编辑的数据,第一次处理时可能无法捕捉到所有的错误。因此,在数据探索阶段期间可能会发现一些错误。尽管如此,我们的诺贝尔奖数据集看起来已经可以使用。我们将宣布它足够干净,这一章的工作完成了。示例 9-4 展示了我们用来完成此清理工作的步骤。

示例 9-4. 完整的诺贝尔奖数据集清理函数
def clean_data(df):
    df = df.replace('', np.nan)
    df_born_in = df[df.born_in.notnull()] ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
    df = df[df.born_in.isnull()]
    df = df.drop('born_in', axis=1)
    df.drop(df[df.year == 1809].index, inplace=True)
    df = df[~(df.name == 'Marie Curie')]
    df.loc[(df.name == 'Marie Sk\u0142odowska-Curie') &\
           (df.year == 1911), 'country'] = 'France'
    df = df[~((df.name == 'Sidney Altman') & (df.year == 1990))]
    df = df.reindex(np.random.permutation(df.index))
    df = df.drop_duplicates(['name', 'year']) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
    df = df.sort_index()
    df.loc[df.name == 'Alexis Carrel', 'category'] =\
        'Physiology or Medicine'
    df.loc[df.name == 'Ragnar Granit', 'gender'] = 'male'
    df = df[df.gender.notnull()] # remove institutional prizes
    df.loc[df.name == 'Hiroshi Amano', 'date_of_birth'] =\
    '11 September 1960'
    df.date_of_birth = pd.to_datetime(df.date_of_birth) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
    df.date_of_death = pd.to_datetime(df.date_of_death,\
    errors='coerce')
    df['award_age'] = df.year - pd.DatetimeIndex(df.date_of_birth)\
    .year
    return df, df_born_in ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/4.png)

1

创建包含具有 born_in 字段的行的 DataFrame。

2

在随机化行顺序后,从 DataFrame 中删除重复项。

3

将日期列转换为实用的 datetime64 数据类型。

4

返回一个删除了 born_in 字段的 DataFrame;这些数据将在下一章节的可视化中提供有趣的展示。

添加 born_in

在清理获奖者 DataFrame 时,我们移除了 born_in 列(请参阅 “删除行”)。如我们将在下一章看到的那样,该列包含一些与获奖者国家(获奖来源国)相关的有趣数据,可以讲述一两个有趣的故事。clean_data 函数返回 born_in 数据作为一个 DataFrame。让我们看看如何将这些数据添加到我们刚刚清理过的 DataFrame 中。首先,我们将读取我们的原始脏数据集,并应用我们的数据清理函数:

df = pd.read_json(open('data/nobel_winners_dirty.json'))
df_clean, df_born_in = clean_data(df)

现在,我们将清理 df_born_in DataFrame 的名称字段,方法是删除星号,去除任何空白,然后通过名称删除任何重复行。最后,我们将 DataFrame 的索引设置为其名称列:

# clean up name column: '* Aaron Klug' -> 'Aaron Klug'
df_born_in.name = dfbi.name.str.replace('*', '', regex=False) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
df_born_in.name = dfbi.name.str.strip()
df_born_in.drop_duplicates(subset=['name'], inplace=True)
df_born_in.set_index('name', inplace=True)

现在我们有一个可以通过名称查询的 df_born_in DataFrame:

In: df_born_in['Eugene Wigner']
Out:
born_in                                                     Hungary
category                                                    Physics
...
year                                                           1963
Name: Eugene Wigner, dtype: object

现在我们将编写一个小的 Python 函数,如果存在于我们的 df_born_in DataFrame 的名称,将返回born_in字段,否则返回 NumPy 的nan

def get_born_in(name):
    try:
        born_in = df_born_in.loc[name]['born_in']
        # We'll print out these rows as a sanity-check
        print('name: %s, born in: %s'%(name, born_in))
    except:
        born_in = np.nan
    return born_in

我们可以通过将 get_born_in 函数应用于每一行的名称字段,向我们的主 DataFrame 添加一个born_in列:

In: df_wbi = df_clean.copy()
In: df_wbi['born_in'] = df_wbi['name'].apply(get_born_in)
Out:
...
name: Christian de Duve, born in: United Kingdom
name: Ilya Prigogine, born in: Russia
...
name: Niels Kaj Jerne, born in: United Kingdom
name: Albert Schweitzer, born in: Germany
...

最后,确保我们已成功向 DataFrame 添加了 born_in 列:

In: df_wbi.info()
Out:
<class 'pandas.core.frame.DataFrame'>
Int64Index: 858 entries, 4 to 1051
Data columns (total 13 columns):
 #   Column          Non-Null Count  Dtype
---  ------          --------------  -----
 0   category        858 non-null    object
 ...
 12  born_in         102 non-null    object
dtypes: datetime64ns, int64(2), object(9)
memory usage: 93.8+ KB

注意,如果我们的诺贝尔获奖者中没有重复的名称,我们可以通过简单地将 dfdf_born_in 的索引设置为 name 并直接创建列来创建 born_in 列:

# this won't work with duplicate names in our index
In: df_wbi['born_in'] = df_born_in.born_in
Out:
...
ValueError: cannot reindex from a duplicate axis

使用 apply 处理大型数据集可能效率低下,但它提供了一种非常灵活的方式来基于现有列创建新列。

合并 DataFrame

此时,我们还可以创建一个合并后的数据库,将我们清理过的 winners 数据与我们在 “使用管道抓取文本和图像” 中抓取的图像和传记数据集合并。这将展示 pandas 合并 DataFrame 的能力。以下代码展示了如何合并 df_clean 和生物数据集:

# Read the Scrapy bio-data into a DataFrame
df_winners_bios = pd.read_json(\ open('data/scrapy_nwinners_minibio.json'))

df_clean_bios = pd.merge(df_wbi, df_winners_bios,\ how='outer', on='link') ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)

1

pandas 的merge接受两个 DataFrame,并根据共享的列名(在本例中为link)将它们合并。how参数指定了如何确定哪些键应包含在结果表中,并且与 SQL 连接的方式相同。在本例中,outer指定了FULL_OUTER_JOIN

合并这两个 DataFrame 会导致我们合并数据集中的冗余数据,超过了 858 个获奖行:

df_clean_bios.count()
Out:
award_age         1023
category          1023
...
bio_image          978
mini_bio          1086

我们可以轻松地使用drop_duplicates来删除任何在删除没有name字段的行之后共享linkyear字段的行:

df_clean_bios = df_clean_bios[~df_clean_bios.name.isnull()]\
.drop_duplicates(subset=['link', 'year'])

快速计数显示,我们现在有 770 位获奖者的正确图像和一个mini_bio

df_clean_bios.count()
award_age         858
category          858
...
born_in           102
bio_image         770
mini_bio          857
dtype: int64

当我们清理数据集时,让我们看看哪个获奖者缺少mini_bio字段:

df_clean_bios[df_clean_bios.mini_bio.isnull()]
Out:
...
                                            link        name  \
229  http://en.wikipedia.org/wiki/L%C3%AA_%C3...  Lê Ðức Thọ
...

原来是在创建越南和平奖获得者 Lê Ðức Thọ的维基百科链接时出现了 Unicode 错误。这可以手工修正。

df_clean_bios DataFrame 包含了我们从维基百科爬取的一系列图片 URL。我们不会使用这些图片,并且它们必须转换为 JSON 格式才能保存到 SQL 中。让我们删除images_url列,以尽可能使我们的数据集清晰:

df_clean_bios.drop('image_urls', axis=1, inplace=True)

现在我们的数据集已经清理和简化,让我们以几种方便的格式保存它。

保存已清理的数据集

现在我们已经有了用于即将进行的 pandas 探索所需的数据集,让我们以几种数据可视化中常见的格式,即 SQL 和 JSON 格式,保存它们。

首先,我们将使用 pandas 方便的to_json方法将带有born_in字段和合并生物的清理 DataFrame 保存为 JSON 文件:

df_clean_bios.to_json('data/nobel_winners_cleaned.json',\
             orient='records', date_format='iso') ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)

1

我们将orient参数设置为records以存储一组行对象,并指定'iso'作为我们日期格式的字符串编码。

让我们将我们干净的 DataFrame 副本保存到本地data目录中的 SQLite nobel_prize数据库中。我们将使用这个数据库来演示第十三章中基于 Flask 的 REST Web API(详见 ch13.xhtml#chapter_delivery_restful)。三行 Python 代码和 DataFrame 的to_sql方法可以简洁地完成这项工作(详见“SQL”获取更多细节):

import sqlalchemy

engine = sqlalchemy.create_engine(\
     'sqlite:///data/nobel_winners_clean.db')
df_clean_bios.to_sql('winners', engine, if_exists='replace')

让我们通过将内容重新读入 DataFrame 来确保我们已成功创建了数据库:

df_read_sql = pd.read_sql('winners', engine)
df_read_sql.info()
Out:
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 858 entries, 0 to 857
Data columns (total 16 columns):
 #   Column          Non-Null Count  Dtype
---  ------          --------------  -----
 0   index           858 non-null    int64
 1   category        858 non-null    object
 2   country         858 non-null    object
 3   date_of_birth   858 non-null    datetime64[ns]
 [...]
 14  bio_image       770 non-null    object
 15  mini_bio        857 non-null    object
dtypes: datetime64ns, float64(2), int64(1), object(11)
memory usage: 107.4+ KB

有了我们清理后的数据在数据库中,我们准备在下一章中开始探索它。

摘要

在这一章中,您学习了如何清理一个相当混乱的数据集,产生了更容易探索和处理的数据。在此过程中,引入了许多新的 pandas 方法和技术,以扩展上一章对基本 pandas 的介绍。

在下一章中,我们将使用新创建的数据集来开始了解诺贝尔奖获得者,他们的国家、性别、年龄以及我们可以找到的任何有趣的相关性(或其缺乏)。

^(1) Large 是一个非常相对的术语,但 pandas 将几乎任何能适合计算机 RAM 内存的东西,这就是 DataFrame 所在的地方。

^(2) pandas 支持使用 MultiIndex 对象的多个索引。这提供了一种非常强大的方式来细化高维数据。在 pandas 文档 中查看详情。

^(3) 默认情况下,pandas 使用 NumPy 的 NaN(不是一个数字)浮点数来表示缺失值。

^(4) 一个有趣的可视化可能是绘制诺贝尔奖获得者从祖国迁徙的图表。

^(5) 参见 IEEE 754 和 维基百科

^(6) 正如你将在下一章中看到的那样,born_in 字段包含有关诺贝尔奖获得者移动的一些有趣信息。我们将在本章末尾看到如何将这些数据添加到清理后的数据集中。

^(7) 尽管法国是居里夫人的养母国,但她保留了波兰国籍,并将她首次发现的放射性同位素命名为 ,以纪念她的祖国。

^(8) 一些用户将此类警告视为多管闲事的偏执狂。参见 Stack Overflow 上的讨论

^(9) 可以通过 pd.options.mode.chained_assignment = None # default=*warn* 来关闭它们。

^(10) 根据数据集,清理阶段不太可能捕捉到所有违规者。

^(11) 虽然格兰尼特的性别在人物数据中未指定,但他的 维基百科传记 使用了男性性别。

第十章:使用 Matplotlib 可视化数据

作为数据可视化者,熟悉数据的最佳方式之一是通过交互式可视化来理解它,使用已演变的各种图表和绘图来总结和优化数据集。传统上,这个探索阶段的成果被呈现为静态图像,但越来越多地被用来构建更具吸引力的基于 Web 的交互式图表,比如您可能见过的酷炫的 D3 可视化之一(我们将在第 V 部分中构建其中之一)。

Python 的 Matplotlib 及其扩展系列(例如统计焦点的 seaborn)形成了一个成熟且非常可自定义的绘图生态系统。Matplotlib 绘图可以通过 IPython(Qt 和笔记本版本)进行交互使用,为您在数据中找到有趣信息提供了非常强大和直观的方式。在本章中,我们将介绍 Matplotlib 及其伟大的扩展之一,seaborn。

pyplot 和面向对象的 Matplotlib

Matplotlib 可能会让人感到相当困惑,特别是如果你随机在网上找例子。主要的复杂因素是有两种主要的绘图方式,它们足够相似以至于容易混淆,但又足够不同以至于会导致许多令人沮丧的错误。第一种方式使用全局状态机直接与 Matplotlib 的pyplot模块交互。第二种面向对象的方法使用更熟悉的图和轴类的概念,提供了一种编程的选择。我将在接下来的章节中澄清它们的差异,但是作为一个粗略的经验法则,如果你正在交互式地处理单个绘图,pyplot的全局状态是一个方便的快捷方式。对于其他所有场合,使用面向对象的方法显式声明你的图和轴是有意义的。

启动交互式会话

我们将使用Jupyter notebook进行交互式可视化。使用以下命令启动会话:

$ jupyter notebook

您可以在 IPython 会话中使用Matplotlib 魔术命令之一来启用交互式 Matplotlib。单独使用%matplotlib将使用默认的 GUI 后端创建绘图窗口,但您可以直接指定后端。以下命令应该适用于标准和 Qt 控制台 IPython:^(1)

%matplotlib [qt | osx | wx ...]

要在笔记本或 Qt 控制台中获取内联图形,可以使用inline指令。请注意,使用内联绘图时,无法在创建后进行修改,不同于独立的 Matplotlib 窗口:

%matplotlib inline

无论您是在交互式环境还是在 Python 程序中使用 Matplotlib,您都会使用类似的导入方式:

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
注意

你将发现许多使用pylab的 Matplotlib 示例。pylab是一个便捷模块,它将matplotlib.pyplot(用于绘图)和 NumPy 批量导入到单一命名空间中。现在pylab基本上已经过时,但即使它没有过时,我仍然建议避免使用这个命名空间,并显式地合并和导入pyplotnumpy

虽然 NumPy 和 pandas 不是强制的,但 Matplotlib 设计时考虑了它们,能处理 NumPy 数组,通过关联处理 pandas Series。

在 IPython 中创建内联图的能力对于与 Matplotlib 的愉快交互至关重要,我们使用以下“魔术”^(2) 指令来实现这一点:

In [0]: %matplotlib inline

现在你的 Matplotlib 图表将插入到你的 IPython 工作流中。这适用于 Qt 和 notebook 版本。在 notebooks 中,图表将被合并到活动单元格中。

修改绘图

在内联模式下,当 Jupyter notebook 单元格或(多行)输入运行后,绘图上下文被刷新。这意味着你不能使用gcf(获取当前图形)方法从先前单元格或输入更改图表,而必须在新的输入/单元格中重复所有绘图命令并进行任何添加或修改。

使用 pyplot 的全局状态进行交互绘图

pyplot模块提供了一个全局状态,你可以进行交互式操作。^(3) 这是用于交互式数据探索的,当你创建简单图表时非常方便。你会看到很多示例使用pyplot,但对于更复杂的绘图,Matplotlib 的面向对象 API(我们马上会看到)更适合。在演示全局绘图使用之前,让我们创建一些随机数据来显示,借助 pandas 有用的period_range方法:

from datetime import datetime

x = pd.period_range(datetime.now(), periods=200, freq='d')![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
x = x.to_timestamp().to_pydatetime() ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
y = np.random.randn(200, 3).cumsum(0) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)

1

使用当前时间(datetime.now())从现在开始创建包含 200 天(d)元素的 pandas datetime索引。

2

datetime索引转换为 Python 的datetimes

3

创建三个包含 200 个元素的随机数组,沿着 0 轴求和。

现在我们有一个包含 200 个时间槽的 y 轴和三个随机数组作为补充的 x 值。这些作为独立的参数提供给(line)plot方法:

plt.plot(x, y)

这给我们展示了不是特别激动人心的图表,如图 10-1 所示。注意 Matplotlib 如何自然地处理多维 NumPy 线性数组。

dpj2 1001

图 10-1. 默认线性图

尽管 Matplotlib 的默认设置普遍被认为不够理想,但它的一个优点是你可以进行大量的自定义。这也是为什么有一个丰富的图表库生态系统,用更好的默认设置、更吸引人的配色方案等封装了 Matplotlib。让我们通过使用原始 Matplotlib 来定制我们的默认图表,看看这种自定义的效果。

配置 Matplotlib

Matplotlib 提供了广泛的配置选项,可以在matplotlibrc 文件中指定,也可以通过类似字典的 rcParams 变量进行动态配置。这里我们改变了图表线条的宽度和默认颜色:

import matplotlib as mpl
mpl.rcParams['lines.linewidth'] = 2
mpl.rcParams['lines.color'] = 'r' # red

你可以在主站点找到一个样例 matplotlibrc 文件。

除了使用 rcParams 变量外,你还可以使用 gcf (获取当前图形)方法直接获取当前活动的图形并对其进行操作。

让我们看一个小例子,配置当前图形的大小。

设置图形大小

如果你的图表默认的可读性较差,或者宽高比不理想,你可能需要改变它的大小。默认情况下,Matplotlib 使用英寸作为其绘图大小的单位。考虑到 Matplotlib 可以保存到许多后端(通常是基于矢量图形的),这是合理的。这里我们展示了两种使用 pyplot 设置图形大小为八乘四英寸的方法,分别是使用 rcParamsgcf

# Two ways to set the figure size to 8 by 4 inches
plt.rcParams['figure.figsize'] = (8,4)
plt.gcf().set_size_inches(8, 4)

点,而不是像素

Matplotlib 使用点而不是像素来测量其图形的大小。这是用于印刷质量出版物的标准度量单位,而 Matplotlib 则用于生成出版质量的图像。

默认情况下,一个点大约是 1/72 英寸宽,但是 Matplotlib 允许你通过更改生成的任何图形的每英寸点数(dpi)来调整此值。数值越高,图像质量越好。为了在 IPython 会话期间交互地显示内联图形,分辨率通常是由用于生成图表的后端引擎(如 Qt、WxAgg、tkinter 等)决定的。查看Matplotlib 文档了解有关后端的解释。

标签和图例

图 10-1 需要告诉我们线的含义,此外还有其他内容。Matplotlib 提供了一个方便的图例框用于标记线条,像大多数 Matplotlib 的功能一样,这也是可以进行大量配置的。标记我们的三条线涉及到一点间接性,因为 plot 方法只接受一个标签,它会应用到生成的所有线条上。幸运的是,plot 命令返回创建的所有 Line2D 对象,这些对象可以被 legend 方法用于设置单独的标签。

因为这张图将会以黑白形式出现(如果您阅读本书的打印版本),我们需要一种方法来区分线条,而不是使用默认的颜色。在 Matplotlib 中实现这一点的最简单方法是顺序创建线条,指定 xy 值以及线型。我们将使用实线 (-)、虚线 (--) 和点划线 (-.) 来创建线条。请注意 NumPy 的列索引用法(见 Figure 7-1):

#plots = plt.plot(x,y)
plots = plt.plot(x, y[:,0], '-', x, y[:,1], '--', x, y[:,2], '-.')
plots
Out:
[<matplotlib.lines.Line2D at 0x9b31a90>,
 <matplotlib.lines.Line2D at 0x9b4da90>,
 <matplotlib.lines.Line2D at 0x9b4dcd0>]

legend方法 可以设置标签,建议图例框的位置,并配置许多其他内容:

plt.legend(plots, ('foo', 'bar', 'baz'), ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
           loc='best', ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
           framealpha=0.5, ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
           prop={'size':'small', 'family':'monospace'}) ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/4.png)

1

设置我们三个图的标签。

2

使用best位置应避免遮挡线条。

3

设置图例的透明度。

4

这里我们调整图例的字体属性:^(5)

标题和轴标签

添加标题和轴标签非常简单:

plt.title('Random trends')
plt.xlabel('Date')
plt.ylabel('Cum. sum')

使用figtext方法可以添加一些文本:^(6)

plt.figtext(0.995, 0.01, ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
            '© Acme designs 2022',
            ha='right', va='bottom') ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)

1

文本相对于图大小的位置。

2

水平(ha)和垂直(va)对齐。

完整的代码显示在 Example 10-1 中,生成的图表在 Figure 10-2 中。

Example 10-1. 自定义折线图
plots = plt.plot(x, y[:,0], '-', x, y[:,1], '--', x, y[:,2], '-.')
plt.legend(plots, ('foo', 'bar', 'baz'), loc='best,
                    framealpha=0.25,
                    prop={'size':'small', 'family':'monospace'})
plt.gcf().set_size_inches(8, 4)
plt.title('Random trends')
plt.xlabel('Date')
plt.ylabel('Cum. sum')
plt.grid(True) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
plt.figtext(0.995, 0.01, '© Acme Designs 2021',
ha='right', va='bottom')
plt.tight_layout() ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)

1

这将在图中添加点状网格,标记轴刻度。

2

tight_layout方法应保证所有的绘图元素都在图框内。否则,可能会发现刻度标签或图例被截断。

dpj2 1002

图 10-2. 自定义折线图

我们在 Example 10-1 中使用了tight_layout方法,以防止绘图元素被遮挡或截断。在某些系统(特别是 macOS)中,tight_layout已知可能会引起问题。如果您遇到任何问题,可以参考这个 问题线程

plt.gcf().set_tight_layout(True)

保存您的图表

Matplotlib 在保存绘图方面表现出色,提供多种输出格式。^(7) 可用的格式取决于可用的后端,但通常支持 PNG、PDF、PS、EPS 和 SVG。PNG 代表便携式网络图形,是分发网络图像的最流行格式。其他格式都是基于矢量的,可以在不产生像素化伪影的情况下平滑缩放。对于高质量的印刷工作,这可能是您想要的格式。

保存操作就像这样简单:

plt.tight_layout() # force plot into figure dimensions
plt.savefig('mpl_3lines_custom.svg')

您可以使用 format="svg" 明确设置格式,但 Matplotlib 也能理解 .svg 后缀。为避免标签截断,使用 tight_layout 方法。^(8)

图形和面向对象的 Matplotlib

正如刚刚展示的,交互式地操作 pyplot 的全局状态对于快速数据草图和单一绘图工作效果良好。然而,如果您希望更多地控制图表,Matplotlib 的图形和坐标轴面向对象(OO)方法是更好的选择。您看到的大多数高级绘图演示都是用这种方式完成的。

从本质上讲,使用面向对象的 Matplotlib,我们处理的是一个图形(figure),可以将其视为一个带有一个或多个坐标轴(或绘图)的绘图画布。图形(figure)和坐标轴(axes)都有可以独立指定的属性。在这个意义上,之前讨论的交互式 pyplot 路线是将绘图绘制到全局图形的单个坐标轴上。

我们可以使用 pyplotfigure 方法创建一个图形:

fig = plt.figure(
          figsize=(8, 4), # figure size in inches
          dpi=200, # dots per inch
          tight_layout=True, # fit axes, labels, etc. to canvas
          linewidth=1, edgecolor='r' # 1 pixel wide, red border
          )

正如您所见,图形与全局 pyplot 模块共享一部分属性。这些属性可以在创建图形时设置,也可以通过类似的方法设置(例如 fig.text() 而不是 plt.fig_text())。每个图形可以有多个轴,每个轴类似于单个全局绘图状态,但具有多个独立属性的优势。

坐标轴和子图

figure.add_axes 方法允许精确控制图形中轴的位置(例如,使您能够在主轴中嵌入一个较小的绘图)。绘图元素的定位使用的是 0 → 1 的坐标系统,其中 1 是图形的宽度或高度。您可以使用四元素列表或元组指定位置,以设置底部左侧和顶部右侧边界:

# h = height, w = width
fig.add_axes([0.2, 0.2, #[bottom(h*0.2), left(w*0.2),
              0.8, 0.8])# top(h*0.8), right(w*0.8)]

示例 10-2 展示了插入较大坐标轴中较小坐标轴所需的代码,使用我们的随机测试数据。结果显示在 图 10-3 中。

示例 10-2. 使用 figure.add_axes 插入图表
fig = plt.figure(figsize=(8,4))
# --- Main Axes
ax = fig.add_axes([0.1, 0.1, 0.8, 0.8])
ax.set_title('Main Axes with Insert Child Axes')
ax.plot(x, y[:,0]) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
ax.set_xlabel('Date')
ax.set_ylabel('Cum. sum')
# --- Inserted Axes
ax = fig.add_axes([0.15, 0.15, 0.3, 0.3])
ax.plot(x, y[:,1], color='g') # 'g' for green
ax.set_xticks([]); ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)

1

这选择了我们随机生成的 NumPy y 数据的第一列。

2

从我们嵌入的绘图中删除 x 轴刻度和标签。

虽然add_axes给了我们很多调整图表外观的空间,但大多数情况下,Matplotlib 内置的网格布局系统使生活变得更加轻松。^(9) 最简单的选项是使用figure.subplots,它允许您指定等大小的行列布局的网格。如果您想要具有不同大小的网格,gridspec模块是您的首选。

dpj2 1003

图例 10-3. 使用figure.add_axes插入绘图

调用不带参数的subplots返回一个带有单个轴的图。这在使用pyplot全局状态机时最接近,如“使用 pyplot 的交互绘图”中所示。示例 10-3 展示了等效于示例 10-1 中pyplot演示的图和轴,生成了图例 10-2 中的图表。请注意使用“setter”方法设置图和轴。

示例 10-3. 使用单个图和轴绘图
figure, ax = plt.subplots()
plots = ax.plot(x, y, label='')
figure.set_size_inches(8, 4)
ax.legend(plots, ('foo', 'bar', 'baz'), loc='best', framealpha=0.25,
          prop={'size':'small', 'family':'monospace'})
ax.set_title('Random trends')
ax.set_xlabel('Date')
ax.set_ylabel('Cum. sum')
ax.grid(True)
figure.text(0.995, 0.01, '©  Acme Designs 2022',
            ha='right', va='bottom')
figure.tight_layout()

调用带有行数(nrows)和列数(ncols)参数的subplots(如示例 10-4 所示)允许将多个绘图放置在网格布局上(见图例 10-4 的结果)。subplots的调用返回图和轴的数组,按行列顺序排列。在本例中,我们指定了一列,因此axes是一个包含三个堆叠轴的单个数组。

示例 10-4. 使用子图
fig, axes = plt.subplots(
                    nrows=3, ncols=1, ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
                    sharex=True, sharey=True, ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
                    figsize=(8, 8))
labelled_data = zip(y.transpose(), ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
                    ('foo', 'bar', 'baz'), ('b', 'g', 'r'))
fig.suptitle('Three Random Trends', fontsize=16)
for i, ld in enumerate(labelled_data):
    ax = axes[i]
    ax.plot(x, ld[0], label=ld[1], color=ld[2])
    ax.set_ylabel('Cum. sum')
    ax.legend(loc='upper left', framealpha=0.5,
              prop={'size':'small'})
axes[-1].set_xlabel('Date') ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/4.png)

1

指定了一个三行一列的子图网格。

2

我们希望共享 x 和 y 轴,自动调整限制以便进行简单比较。

3

将 y 切换为行列,并将线数据、标签和线颜色一起压缩。

4

给最后一个共享的 x 轴加上标签。

dpj2 1004

图例 10-4. 三个子图

我们利用 Python 便利的zip方法生成了包含线数据的三个字典。zip接受长度为n的列表或元组,并返回n个列表,按顺序匹配元素:

letters = ['a', 'b']
numbers = [1, 2]
zip(letters, numbers)
Out:
[('a', 1), ('b', 2)]

for循环中,我们使用enumerate为索引i提供了一个轴,使用我们的labelled_data来提供绘图属性。

注意在subplots调用中指定的共享 x 和 y 轴,如示例 10-4(2)所示。这样可以在现在标准化的 y 轴上轻松比较三个图表,为了避免冗余的 x 标签,我们仅在最后一行调用set_xlabel,使用 Python 方便的负索引。

现在我们已经讨论了 IPython 和 Matplotlib 交互式使用的两种方式,即使用全局状态(通过plt访问)和面向对象的 API,让我们看看您将用来探索数据集的几种常见绘图类型。

绘图类型

除了刚才演示的折线图外,Matplotlib 还有许多其他类型的图表可用。接下来我将演示几种在探索性数据可视化中常用的类型。

条形图

朴素的条形图是许多视觉数据探索中的重要工具。与大多数 Matplotlib 图表一样,可以进行大量自定义。我们将介绍几个变体,帮助您理解其要领。

示例 10-5 中的代码生成了 图 10-5 的条形图。请注意,您需要指定自己的条和标签位置。这种灵活性深受 Matplotlib 爱好者的喜爱,并且相当容易上手。尽管如此,有时这样的工作可能会显得乏味。编写一些辅助方法非常简单,此外还有许多封装了 Matplotlib 的库,使得操作更加用户友好。正如我们将在 第十一章 中看到的那样,基于 pandas 的 Matplotlib 绘图功能要简单得多。

示例 10-5. 简单的条形图
labels = ["Physics", "Chemistry", "Literature", "Peace"]
foo_data =   [3, 6, 10, 4]

bar_width = 0.5
xlocations = np.array(range(len(foo_data))) + bar_width ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
plt.bar(xlocations, foo_data, width=bar_width)
plt.yticks(range(0, 12)) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
plt.xticks(xlocations, labels) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
plt.title("Prizes won by Fooland")
plt.gca().get_xaxis().tick_bottom()
plt.gca().get_yaxis().tick_left()
plt.gcf().set_size_inches((8, 4))

1

在这里,我们创建了中间条的位置,两个 bar_width 之间相隔。

2

为了演示目的,我们在此硬编码了 x 值,通常您会希望动态计算范围。

3

这会将刻度标签放置在条的中间。

dpj2 1005

图 10-5. 简单的条形图

多组条形图尤其有用。在 示例 10-6 中,我们添加了更多的国家数据(来自一个虚构的 Barland),并使用 subplots 方法生成了分组条形图(见 图 10-6)。再次手动指定条的位置,使用 ax.bar 添加了两组条形图。请注意,我们的轴的 x 范围会以合理的方式自动重新缩放,以增量为 0.5:

ax.get_xlim()
# Out: (-0.5, 3.5)

如果自动缩放不能达到预期效果,请使用相应的设置方法(例如此处的 set_xlim)。

示例 10-6. 创建分组条形图
labels = ["Physics", "Chemistry", "Literature", "Peace"]
foo_data = [3, 6, 10, 4]
bar_data = [8, 3, 6, 1]

fig, ax = plt.subplots(figsize=(8, 4))
bar_width = 0.4 ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
xlocs = np.arange(len(foo_data))
ax.bar(xlocs-bar_width, foo_data, bar_width,
       color='#fde0bc', label='Fooland') ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
ax.bar(xlocs, bar_data, bar_width, color='peru', label='Barland')
#--- ticks, labels, grids, and title
ax.set_yticks(range(12))
ax.set_xticks(ticks=range(len(foo_data)))
ax.set_xticklabels(labels)
ax.yaxis.grid(True)
ax.legend(loc='best')
ax.set_ylabel('Number of prizes')
fig.suptitle('Prizes by country')
fig.tight_layout(pad=2) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
fig.savefig('mpl_barchart_multi.png', dpi=200) ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/4.png)

1

对于我们的双条组,使用 1 的宽度,这个条宽度提供了 0.1 的条填充。

2

Matplotlib 支持标准的 HTML 颜色,可以使用十六进制值或名称。

3

我们使用 pad 参数来指定围绕图像的填充,其值为字体大小的一部分。

4

这将以每英寸 200 点的高分辨率保存图像。

dpj2 1006

图 10-6. 分组条形图

如果条形图很多并且使用刻度标签,横向放置它们通常更有用,因为标签可能会相互重叠在同一行。将 图 10-6 转为水平方向很容易,只需将 bar 方法替换为其水平对应方法 barh,并交换轴标签和限制(参见 示例 10-7 和生成的图表 图 10-7)。

示例 10-7. 将 示例 10-6 转换为水平条形图
# ...
ylocs = np.arange(len(foo_data))
ax.barh(ylocs-bar_width, foo_data, bar_width, color='#fde0bc',
        label='Fooland') ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
ax.barh(ylocs, bar_data, bar_width, color='peru', label='Barland')
# --- labels, grids and title, then save
ax.set_xticks(range(12)) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
ax.set_yticks(ticks=ylocs-bar_width/2)
ax.set_yticklabels(labels)
ax.xaxis.grid(True)
ax.legend(loc='best')
ax.set_xlabel('Number of prizes')
# ...

1

要创建水平条形图,我们使用 barh 替代 bar

2

水平图表需要交换水平和垂直轴。

dpj2 1007

图 10-7. 将条形图横置

在 Matplotlib 中实现堆叠条形图很容易。(参见 10)示例 10-8 将 图 10-6 转为堆叠形式;图 10-8 展示了结果。使用 bar 方法的 bottom 参数将提升条的底部设置为前一组的顶部。

示例 10-8. 将 示例 10-6 转换为堆叠条形图
# ...
bar_width = 0.8
xlocs = np.arange(len(foo_data))
ax.bar(xlocs, foo_data, bar_width, color='#fde0bc', ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
       label='Fooland')
ax.bar(xlocs, bar_data, bar_width, color='peru',    ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
       label='Barland', bottom=foo_data)
# --- labels, grids and title, then save
ax.set_yticks(range(18))
ax.set_xticks(ticks=xlocs)
ax.set_xticklabels(labels)
# ...

1

foo_databar_data 条形图组共享相同的 x 轴位置。

2

bar_data 组的底部是 foo_data 组的顶部,形成了堆叠条形图。

dpj2 1008

图 10-8. 堆叠条形图

散点图

另一个有用的图表是散点图,它接受点大小、颜色等选项的 2D 数组。

示例 10-9 显示了一个快速散点图的代码,使用 Matplotlib 自动调整 x 和 y 的限制。我们通过添加正态分布的随机数(标准差为 10)创建了一条嘈杂的线。图 10-9 展示了生成的图表。

示例 10-9. 简单的散点图
num_points = 100
gradient = 0.5
x = np.array(range(num_points))
y = np.random.randn(num_points) * 10 + x*gradient ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
fig, ax = plt.subplots(figsize=(8, 4))
ax.scatter(x, y) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)

fig.suptitle('A Simple Scatterplot')

1

randn 函数提供正态分布的随机数,我们将其缩放到 0 到 10 的范围内,并且添加一个依赖 x 轴的值。

2

x 和 y 数组的大小相等,提供了点的坐标。

dpj2 1009

图 10-9. 简单的散点图

通过将标记大小和颜色索引数组传递给当前默认的颜色映射,我们可以调整单个点的大小和颜色。需要注意的一点(可能会令人困惑的一点)是,我们指定的是标记边界框的面积,而不是圆的直径。这意味着,如果我们希望点的直径是圆的两倍,我们必须将大小增加四倍。^(11) 在 示例 10-10 中,我们向简单的散点图添加了大小和颜色信息,生成了 图 10-10。

示例 10-10. 调整点的大小和颜色
num_points = 100
gradient = 0.5
x = np.array(range(num_points))
y = np.random.randn(num_points) * 10 + x*gradient
fig, ax = plt.subplots(figsize=(8, 4))
colors = np.random.rand(num_points) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
size = np.pi * (2 + np.random.rand(num_points) * 8) ** 2 ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
ax.scatter(x, y, s=size, c=colors, alpha=0.5) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
fig.suptitle('Scatterplot with Color and Size Specified')

1

这会生成默认颜色映射的 100 个随机颜色值,取值范围在 0 到 1 之间。

2

我们使用幂符号 ** 对介于 2 到 10 之间的值进行平方,这是我们标记宽度范围的方法。

3

我们使用 alpha 参数使我们的标记半透明。

dpj2 1010

图 10-10. 调整点的大小和颜色

Matplotlib 颜色映射

Matplotlib 提供了大量的颜色映射可供选择,选择适当的颜色映射可以显著提高可视化质量。请参阅 颜色映射文档 了解详情。

添加回归线

回归线是两个变量之间相关性的简单预测模型,本例中是散点图的 x 和 y 坐标。该线基本上是通过图的点拟合而成,并且将其添加到散点图中是一种有用的数据可视化技术,也是演示 Matplotlib 和 NumPy 交互的良好方式。

在 示例 10-11 中,NumPy 的非常有用的 polyfit 函数用于生成由 x 和 y 数组定义的点的最佳拟合直线的梯度和常数。然后,我们在相同的坐标轴上绘制这条直线和散点图(参见 图 10-11)。

示例 10-11. 带有回归线的散点图
num_points = 100
gradient = 0.5
x = np.array(range(num_points))
y = np.random.randn(num_points) * 10 + x*gradient
fig, ax = plt.subplots(figsize=(8, 4))
ax.scatter(x, y)
m, c = np.polyfit(x, y ,1) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
ax.plot(x, m*x + c) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
fig.suptitle('Scatterplot With Regression-line')

1

我们使用 NumPy 的 polyfit 在 1D 中获取一条最佳拟合直线通过我们随机点的线性梯度 (m) 和常数 (c)。

2

使用梯度和常数在散点图的坐标轴上绘制一条直线 (y = mx + c)。

dpj2 1011

图 10-11. 带有回归线的散点图

在进行线性回归时,通常建议绘制置信区间。这可以根据点的数量和分布给出线性拟合的可靠性概念。可以使用 Matplotlib 和 NumPy 实现置信区间,但操作起来有点麻烦。幸运的是,有一个基于 Matplotlib 构建的库,它具有额外的专业函数用于统计分析和数据可视化,并且在许多人看来比 Matplotlib 的默认视觉效果更好。这个库就是 seaborn,现在我们来简单看一下。

seaborn

有许多库将 Matplotlib 强大的绘图能力封装成更用户友好的形式^(12),对于我们这些数据可视化者来说,这些库与 pandas 的兼容性非常好。

Bokeh是一个专为网络设计的交互式可视化库,产生浏览器渲染的输出,因此非常适合 IPython 笔记本。它是一个伟大的成就,设计哲学与 D3 类似。^(13)

但是,要进行交互式、探索性数据可视化,以便对数据有所感觉并建议可视化方法,我推荐使用seaborn。seaborn 通过一些强大的统计图扩展了 Matplotlib,并且与 PyData 堆栈非常好地集成,与 NumPy、pandas 以及在 SciPy 和statsmodels中找到的统计例程良好地配合。

seaborn 的一个好处是不隐藏 Matplotlib 的 API,允许您使用 Matplotlib 丰富的工具调整图表。从这个意义上说,它并不取代 Matplotlib 及其相关技能,而是一个非常令人印象深刻的扩展。

要使用 seaborn,只需扩展您的标准 Matplotlib 导入:

import numpy as np
import pandas as pd
import seaborn as sns # relies on matplotlib
import matplotlib as mpl
import matplotlib.pyplot as plt

Matplotlib 提供了许多绘图样式,可以通过调用use方法设置当前样式为 seaborn 的默认样式,这将为图表提供一个微妙的灰色网格:

matplotlib.style.use('seaborn')

你可以在Matplotlib 文档中查看所有可用的样式及其视觉效果。

seaborn 的许多函数都设计成接受 pandas DataFrame,你可以指定描述二维散点的列值,例如来自示例 10-9 的现有 x 和 y 数组。让我们使用它们来生成一些虚拟数据:

data = pd.DataFrame({'dummy x':x, 'dummy y':y})

现在我们有一些带有 x('dummy x')和 y('dummy y')值的data。示例 10-12 演示了使用 seaborn 专用的线性回归绘图lmplot,生成了图 10-12 中的图表。请注意,对于某些 seaborn 绘图,我们可以通过传递以英寸为单位的大小(高度)和宽高比来调整图形大小。还请注意,seaborn 共享pyplot的全局上下文。

示例 10-12. 使用 seaborn 进行线性回归绘图
data = pd.DataFrame({'dummy x':x, 'dummy y':y})
sns.lmplot(data=data, x='dummy x', y='dummy y', ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
           height=4, aspect=2) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
plt.tight_layout() ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
plt.savefig('mpl_scatter_seaborn.png') ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)

1

xy 参数指定了定义图形点坐标的 DataFrame 数据的列名。

2

为了设置图形大小,我们提供以英寸为单位的高度和宽高比。在这里,我们将使用 2 的比率以更好地适应本书的页面格式。

3

seaborn 共享 pyplot 的全局上下文,允许您保存其绘制的图像,就像使用 Matplotlib 一样。

dpj2 1012

Figure 10-12. 使用 seaborn 的线性回归图

正如你所期待的那样,seaborn 这个强调画出吸引人的图形的库,允许大量的视觉定制。让我们对 Figure 10-12 的外观进行一些改变,并将置信区间调整为 68% 的标准误估计(查看 Figure 10-13 的结果):

sns.lmplot(data=data, x='dummy x', y='dummy y', height=4, aspect=2,
           scatter_kws={"color": "slategray"}, ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
           line_kws={"linewidth": 2, "linestyle":'--', ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
                     "color": "seagreen"},
           markers='D', ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
           ci=68) ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/4.png)

1

提供散点图组件的关键字参数,将点的颜色设置为石板灰。

2

提供线图组件的关键字参数,设置线宽和样式。

3

将 Matplotlib 的标记设置为钻石,使用 Matplotlib 标记代码 D

4

我们设置了 68% 的置信区间,即标准误估计。

dpj2 1013

Figure 10-13. 自定义 seaborn 散点图

seaborn 提供了一些比 Matplotlib 基本设置更有用的图形。让我们来看看其中最有趣的之一,使用 seaborn 的 FacetGrid 绘制多维数据的反射。

FacetGrids

常被称为“格栅”或“镶嵌”绘图,能够在数据集的不同子集上绘制多个相同图形实例的能力,是一种鸟瞰数据的好方法。大量信息可以在一个图中呈现,并且可以快速理解不同维度之间的关系。这种技术与 Edward Tufte 推广的“小多面板图”有关。

FacetGrids 要求数据以 pandas DataFrame 的形式存在(参见 “DataFrame”),并且按照 Hadley Wickham 的说法,应该是“整洁”的形式,意味着 DataFrame 的每一列应该是一个变量,每一行是一个观察结果。

让我们使用 Tips,seaborn 的一个测试数据集,(14)展示 FacetGrid 的工作原理。Tips 是一个小数据集,显示了小费的分布情况,根据不同维度如周几或顾客是否吸烟。(15)首先,让我们使用load_dataset方法将 Tips 数据加载到 pandas DataFrame 中:

In [0]: tips = sns.load_dataset('tips')
Out[0]:
     total_bill   tip     sex smoker   day    time  size
0         16.99  1.01  Female     No   Sun  Dinner     2
1         10.34  1.66    Male     No   Sun  Dinner     3
2         21.01  3.50    Male     No   Sun  Dinner     3
3         23.68  3.31    Male     No   Sun  Dinner     2
...

要创建一个 FacetGrid,我们指定tips DataFrame 和一个感兴趣的列,如顾客的吸烟状态。该列将用于创建我们的绘图组。吸烟列中有两个类别('smoker=Yes'和'smoker=No'),这意味着我们的 facet-grid 中将有两个图表。然后,我们使用 grid 的map方法创建小费与总账单的多个散点图:

g = sns.FacetGrid(tips, col="smoker", height=4, aspect=1)
g.map(plt.scatter, "total_bill", "tip") ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)

1

map接受一个绘图类,本例中为scatter,以及此散点图所需的两个(tips)维度。

这将产生两个散点图,如 Figure 10-14 所示,一个用于每种吸烟状态,显示了小费与总账单的相关性。

dpj2 1014

Figure 10-14. 使用散点图的 seaborn FacetGrid

我们可以通过指定要在散点图中使用的标记来包含tips数据的另一个维度。让我们将其设置为红色菱形表示女性,蓝色方形表示男性:

pal = dict(Female='red', Male='blue')
g = sns.FacetGrid(tips, col="smoker",
                  hue="sex", hue_kws={"marker": ["D", "s"]}, ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
                  palette=pal, height=4, aspect=1, )
g.map(plt.scatter, "total_bill", "tip", alpha=.4)
g.add_legend();

1

sex维度添加了标记颜色(hue),使用了菱形(D)和方形(s)形状,并使用我们的调色板(pal)使它们呈现红色和蓝色。

您可以在 Figure 10-15 中看到生成的 FacetGrid。

dpj2 1015

Figure 10-15. Scatter plot with diamond and square markers for sex

我们可以使用行和列来创建数据维度的子集。结合一个regplot,^(16)可以探索五个维度:

pal = dict(Female='red', Male='blue')
g = sns.FacetGrid(tips, col="smoker", row="time", ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
                  hue="sex", hue_kws={"marker": ["D", "s"]},
                  palette=pal, height=4, aspect=1, )
g.map(sns.regplot, "total_bill", "tip", alpha=.4)
g.add_legend();

1

添加一个时间行,将小费按午餐和晚餐分开。

Figure 10-16 展示了四个regplot,为女性和男性 hue 组生成带有置信区间的线性回归模型拟合。图表标题显示正在使用的数据子集,每行具有相同的时间和吸烟者状态。

dpj2 1016

Figure 10-16. Visualizing five dimensions

我们可以通过lmplot来实现与我们在 Example 10-12 中看到的相同效果,它封装了 FacetGrid 和regplot以便于使用。以下代码生成 Figure 10-16:

pal = dict(Female='red', Male='blue')
sns.lmplot(x="total_bill", y="tip", hue="sex",
           markers=["D", "s"], ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
           col="smoker", row="time", data=tips, palette=pal,
           height=4, aspect=1
           );

1

注意使用markers关键字,而不是我们在 FacetGrid 图中使用的kws_hue字典。

lmplot 提供了一个很好的快捷方式来生成 FacetGrid 的 regplot,但是 FacetGrid 的 map 允许您使用 seaborn 和 Matplotlib 图表的全套来在维度子集上创建图表。这是一种非常强大的技术,也是深入了解数据的一个很好的方式。

PairGrid

PairGrid 是另一种相当酷的 seaborn 绘图类型,提供了一种快速评估多维数据的方式。与 FacetGrid 不同,您不会将数据集分成子集,然后按指定的维度进行比较。使用 PairGrid,数据集的各个维度将按成对方式在一个方形网格中进行比较。默认情况下,将比较所有维度,但是您可以通过在声明 PairGrid 时向 vars 参数提供列表来指定要绘制的维度。^(17)

我们通过使用经典的 Iris 数据集来演示这种成对比较的效用,展示包含三种 Iris 种类成员的一组数据的一些重要统计信息。首先,我们加载示例数据集:

In [0]: iris = sns.load_dataset('iris')
In [1]: iris.head()
Out[1]:
   sepal_length sepal_width  petal_length  petal_width  species
0           5.1         3.5           1.4          0.2   setosa
1           4.9         3.0           1.4          0.2   setosa
2           4.7         3.2           1.3          0.2   setosa
...

为了捕捉按物种分组的花瓣和萼片尺寸之间的关系,我们首先创建一个 PairGrid 对象,将其色调设置为 species,然后使用其映射方法在成对网格的对角线上和非对角线上创建图表,生成图表见 Figure 10-17:

sns.set_theme(font_scale=1.5) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
g = sns.PairGrid(iris, hue="species") ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
g.map_diag(plt.hist) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
g.map_offdiag(plt.scatter) ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/4.png)
g.add_legend();

1

使用 seaborn 的 set_theme 方法调整字体大小(参见文档获取可用调整项的完整列表)。

2

将标记和子条设置为按物种着色。

3

在网格的对角线上放置物种尺寸的直方图。

4

使用标准散点图比较对角线尺寸。

dpj2 1017

图 10-17. Iris 测量的 PairGrid 汇总

正如您在 Figure 10-17 中看到的,seaborn 的几行代码就能创建一组丰富信息的图表,相关联不同 Iris 测量指标。这种图表称为 散点矩阵,是在多变量集中查找变量对线性相关性的一个很好的方式。目前,网格中存在冗余:例如,sepal_width-petal_lengthpetal_length-sepal_width 的图表。PairGrid 提供了使用主对角线之上或之下的冗余图表来提供数据的不同反映的机会。查看一些 seaborn 文档中的示例以获取更多信息。^(18)

在本节中,我介绍了一些 seaborn 的图表,下一章我们在探索新贵数据集时将看到更多。但 seaborn 还有许多其他非常方便和强大的统计工具。进一步调查时,我建议从 seaborn 主要文档 开始。那里有一些很好的示例、完整的 API 文档和一些好的教程,可以补充您在本章学到的内容。

总结

本章介绍了 Python 绘图的强大工具 Matplotlib。它是一个成熟的大型库,拥有丰富的文档和活跃的社区。如果您有特定的定制需求,很可能在某处能找到示例。我建议启动一个 Jupyter 笔记本 并尝试使用数据集进行操作。

我们看到 seaborn 通过一些有用的统计方法扩展了 Matplotlib,并且许多人认为它具有更优美的美学。它还允许访问 Matplotlib 图形和坐标轴的内部,如果需要的话可以进行完全自定义。

在下一章中,我们将结合 pandas 使用 Matplotlib 探索我们新获取并清理的诺贝尔数据集。我们将使用本章中演示的一些图表类型,看到一些有用的新功能。

^(1) 如果启动 GUI 会话时出现错误,请尝试更改后端设置(例如,如果在 macOS 上使用 %matplotlib qt 无效,请尝试 %matplotlib osx)。

^(2) IPython 拥有大量这样的函数,可以为普通的 Python 解释器提供一整套有用的额外功能。请查看 IPython 网站

^(3) 这受到 MATLAB 的启发。

^(4) 您可以在 Matplotlib 的文档 中找到有关线型的详细信息。

^(5) 更多细节请参阅 文档

^(6) 有关详情,请参阅 Matplotlib 网站

^(7) 除了提供多种格式外,它还理解 LaTeX 数学模式,这是一种语言,允许您在标题、图例等处使用数学符号。这是 Matplotlib 受到学术界喜爱的原因之一,因为它能够生成期刊质量的图像。

^(8) 更多详细信息,请访问 Matplotlib 网站

^(9) 便捷的 tight_layout 选项假定网格布局子图。

^(10) 叠加条形图是否特别适合理解数据组?请参阅Solomon Messing 的博客,进行精彩的讨论,并提供一个“好”使用的例子。

^(11) 设置标记大小而不是宽度或半径,实际上是一个很好的默认选择,使其与我们试图反映的任何值成比例。

^(12) 普遍认为 Matplotlib 的默认设置并不那么好,通过改进可以轻松提升任何包的表现。

^(13) D3 和 Bokeh 都向经典的可视化著作《图形语法》(Springer,Leland Wilkinson 著)致敬。

^(14) seaborn 有一些方便的数据集,你可以在GitHub上找到。

^(15) Tips 数据集使用性别作为一个类别,而本书的数据集使用性别。过去这些词汇通常可以互换使用,但现在情况已经不同。请参阅Yale School of Medicine 的文章以获取解释。

^(16) regplot,即回归图,相当于lmplot,在示例 10-12 中有使用。后者结合了regplot和 FacetGrid 以提供便利。

^(17) 还有x_varsy_vars参数,使您能够指定非方形网格。

^(18) 如果你感兴趣,有一个 D3 的例子在bl.ocks.org站点上构建了一个散点矩阵。

第十一章:利用 pandas 探索数据

在上一章中,我们清理了从维基百科中抓取的诺贝尔奖数据集,详见 第 6 章。现在是时候开始探索我们光鲜亮丽的新数据集了,寻找有趣的模式、可讲述的故事以及任何其他可以成为有趣可视化基础的东西。

首先,让我们尝试清空我们的思绪,认真地审视手头的数据,以获得对所建议可视化的整体概念。 Example 11-1 展示了诺贝尔数据集的形式,其中包含了分类、时间和地理数据。

例 11-1. 我们清理过的诺贝尔奖数据集
[{
 'category': 'Physiology or Medicine',
 'date_of_birth': '8 October 1927',
 'date_of_death': '24 March 2002',
 'gender': 'male',
 'link': 'http://en.wikipedia.org/wiki/C%C3%A9sar_Milstein',
 'name': 'César Milstein'
 'country': 'Argentina',
 'place_of_birth': 'Bahía Blanca,  Argentina',
 'place_of_death': 'Cambridge , England',
 'year': 1984,
 'born_in': NaN
 },
 ...
 ]

Example 11-1 中的数据表明我们可能想要调查的一些 故事,其中包括:

  • 奖项获得者之间的性别差异

  • 国家趋势(例如,哪个国家在经济学领域获得了最多的奖项)

  • 个别获奖者的详细信息,例如他们获奖时的平均年龄或预期寿命

  • 利用 born_incountry 字段,从出生地到采纳国的地理旅程

这些调查性的行将成为接下来的部分的基础,这些部分将通过对数据集提出问题来探索数据集,比如“除了玛丽·居里之外,还有多少名女性获得了诺贝尔物理学奖?”,“哪些国家的奖项人均数量最多而不是绝对数量?”以及“是否有国家奖项的历史趋势,从旧(科学)世界(欧洲大国)到新(美国和即将崛起的亚洲国家)的交替?”在开始我们的探索之前,让我们准备好工具并加载我们的诺贝尔奖数据集。

开始探索

要开始我们的探索,让我们从命令行启动一个 Jupyter 笔记本:

$ jupyter notebook

我们将使用 神奇matplotlib 命令来启用内联绘图:

%matplotlib inline

然后导入标准的数据探索模块集:

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import json
import matplotlib
import seaborn as sns

现在,我们将对绘图参数和图表的一般外观进行一些调整。确保在调整图形大小、字体和其他内容之前更改样式:

matplotlib.style.use('seaborn') ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)

plt.rcParams['figure.figsize'] = (8, 4) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
plt.rcParams['font.size'] = '14'

1

我们将为我们的图表使用 seaborn 主题,这在美观程度上可能比 Matplotlib 的默认主题更具吸引力。

2

将默认绘图大小设置为八英寸乘四英寸。

在 第 9 章 的最后,我们将我们的清洁数据集保存为一个 JSON 文件。让我们将清洁的数据加载到 pandas DataFrame 中,准备开始探索。

df = pd.read_json(open('data/nobel_winners_cleaned.json'))

让我们获取一些关于我们数据集结构的基本信息:

df.info()

<class 'pandas.core.frame.DataFrame'>
RangeIndex: 858 entries, 0 to 857
Data columns (total 13 columns):
 #   Column          Non-Null Count  Dtype
---  ------          --------------  -----
 0   category        858 non-null    object
 1   country         858 non-null    object
 2   date_of_birth   858 non-null    object
 3   date_of_death   559 non-null    object
 4   gender          858 non-null    object
 5   link            858 non-null    object
 6   name            858 non-null    object
 7   place_of_birth  831 non-null    object
 8   place_of_death  524 non-null    object
 9   text            858 non-null    object
 10  year            858 non-null    int64
 11  award_age       858 non-null    int64
 12  born_in         102 non-null    object
 13  bio_image       770 non-null    object
 14  mini_bio        857 non-null    object
dtypes: int64(2), object(13)
memory usage: 100.7+ KB

请注意,我们的出生日期和死亡日期列具有标准的 pandas 数据类型 object。为了进行日期比较,我们需要将其转换为 datetime 类型 datetime64。我们可以使用 pandas 的 to_datetime 方法 进行此转换:

df.date_of_birth = pd.to_datetime(df.date_of_birth)
df.date_of_death = pd.to_datetime(df.date_of_death)

运行 df.info() 现在应该显示两个 datetime 列:

df.info()

...
date_of_birth     858 non-null datetime64[ns, UTC] ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
date_of_death     559 non-null datetime64[ns, UTC]
...

1

UTC(英文中的协调世界时)是世界上调整时钟和时间的主要时间标准。几乎总是希望按照这个标准工作。

to_datetime通常不需要额外的参数即可工作,并且如果给出非基于时间的数据,应该会抛出错误,但是值得检查转换后的列以确保。在我们的诺贝尔奖数据集的情况下,一切都很正常。

使用 pandas 进行绘图

pandas 的 Series 和 DataFrame 都有集成绘图功能,它包含了最常见的 Matplotlib 图表,我们在上一章中已经探讨过其中的一些。这使得在与 DataFrame 交互时轻松获得快速的视觉反馈。如果你想要可视化更复杂的内容,pandas 容器将与原始的 Matplotlib 很好地配合。你还可以使用标准的 Matplotlib 自定义来调整 pandas 生成的图形。

让我们看一个 pandas 集成绘图的例子,从一个基本的诺贝尔奖性别差异的绘图开始。众所周知,诺贝尔奖在各性别之间分配不均。让我们通过在性别类别上使用条形图来快速了解这种差异。示例 11-2 生成了图 11-1,显示了巨大的差异,男性在我们的数据集中获得了 858 项奖项中的 811 项。

示例 11-2. 使用 pandas 的集成绘图查看性别差异
by_gender = df.groupby('gender')
by_gender.size().plot(kind='bar')

dpj2 1101

图 11-1. 按性别计算奖项数

在示例 11-2 中,通过性别组的size方法产生的 Series 有其自己的集成plot方法,它将原始数字转换为图表:

by_gender.size()
Out:
gender
female     47
male      811
dtype: int64

除了默认的线图外,pandas 的plot方法还接受一个kind参数来选择其他可能的图。其中更常用的是:

  • barbarhh 表示水平)用于条形图

  • hist 用于直方图

  • box 用于箱线图

  • scatter 用于散点图

你可以在文档中找到 pandas 集成绘图的完整列表,以及一些以 DataFrame 和 Series 作为参数的 pandas 绘图函数。

让我们扩展对性别差异的调查,并开始扩展我们的绘图技能。

性别差异

让我们通过奖项类别来分析图 11-1 中显示的性别数字。pandas 的groupby方法可以接受一个列名列表进行分组,每个组可以通过多个键来访问:

by_cat_gen = df.groupby(['category','gender'])

by_cat_gen.get_group(('Physics', 'female'))[['name', 'year']] ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)

1

使用categorygender键获取一个分组:

Out:
                       name  year
269    Maria Goeppert-Mayer  1963
612  Marie Skłodowska-Curie  1903

使用size方法获取这些组的大小将返回一个带有MultiIndex的 Series,通过类别和性别标记值:

by_cat_gen.size()
Out:
category                gender
Chemistry               female      4
                        male      167
Economics               female      1
                        male       74
...
Physiology or Medicine  female     11
                        male      191
dtype: int64

我们可以直接绘制这个多索引的 Series,使用hbar作为kind参数以生成水平条形图。这段代码生成 图 11-2。

by_cat_gen.size().plot(kind='barh')

dpj2 1102

图 11-2. 绘制多关键组

图 11-2 有些粗糙,使得比较性别差距比应有的更难。让我们继续完善我们的图表,使这些差距更加明显。

组展开

图 11-2 并不是最容易阅读的图表,即使我们改进了条形的排序。幸运的是,pandas Series 有一个很酷的unstack方法,它接受多个索引(在本例中是性别和类别)并将它们用作列和索引,从而创建一个新的数据框。绘制此数据框将得到一个更加可用的图表,因为它比较了按性别获奖的奖品。以下代码生成图 11-3:

by_cat_gen.size().unstack().plot(kind='barh')

dpj2 1103

图 11-3. 组大小的展开系列

图 11-3 显示了男性和女性获奖数量之间的巨大差异。让我们通过使用 pandas 生成一个显示按类别百分比的女性获奖者的图表,使数据更具体。我们还将按奖品数量对类别条进行排序。

首先,我们将展开by_cat_gen组以生成一个cat_gen_sz数据框:

cat_gen_sz = by_cat_gen.size().unstack()
cat_gen_sz.head()
gender      female  male
category
Chemistry        4   167
Economics        1    74
Literature      13    93
Peace           16    87
Physics          2   199

为了演示目的,我们将在两个阶段进行 pandas 操作,使用两列新数据存储我们的新数据。首先,我们将制作一个包含女性获奖者比例的列,与总获奖者数的比值:

cat_gen_sz['ratio'] = cat_gen_sz.female /\ ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
                     (cat_gen_sz.female + cat_gen_sz.male)
cat_gen_sz.head()

1

尴尬的斜杠阻止 Python 崩溃,但这是一个除法操作。

ender      female  male     ratio
category
Chemistry        4   167  0.023392
Economics        1    74  0.013333
Literature      13    93  0.122642
Peace           16    87  0.155340
Physics          2   199  0.009950

有了比例列后,我们可以通过将该比例乘以 100 来创建一个包含女性获奖者百分比的列:

cat_gen_sz['female_pc'] = cat_gen_sz['ratio'] * 100

让我们将这些女性百分比绘制在水平条形图上,设置 x 轴限制为 100%,并按奖项数量对类别进行排序:

cat_gen_sz = cat_gen_sz.sort_values(by='female_pc', ascending=True)
ax = cat_gen_sz[['female_pc']].plot(kind='barh')
ax.set_xlim([0, 100])
ax.set_xlabel('% of female winners')

您可以在图 11-4 中看到新的图表,清楚地显示了按性别划分的奖项总数的差异。

dpj2 1104

图 11-4. 按奖项类别百分比的女性获奖者

忽略经济学,这是诺贝尔奖类别的最近而有争议的增加,图 11-4 显示物理学中男女获奖者数量最大的差距,只有两位女性获奖者。让我们提醒一下她们是谁:

df[(df.category == 'Physics') & (df.gender == 'female')]\
    [['name', 'country','year']]
Out:
                       name    country  year
269    Maria Goeppert-Mayer  United States  1963
612  Marie Skłodowska-Curie         Poland  1903

大多数人都听说过玛丽·居里,她实际上是两位诺贝尔奖获得者中的四位杰出人物之一,但很少有人听说过玛丽亚·歌柏·迈耶。^(1) 这种无知令人惊讶,考虑到鼓励女性从事科学的努力。我希望我的可视化能帮助人们发现并了解一点玛丽亚·歌柏·迈耶的事迹。

历史趋势

很有趣的是看看最近几年女性奖项分配是否有所增加。一种可视化的方法是随时间分组的条形图。让我们快速绘制一个图表,使用unstack,如图 11-3,但使用年份和性别列:

by_year_gender = df.groupby(['year','gender'])
year_gen_sz = by_year_gender.size().unstack()
year_gen_sz.plot(kind='bar', figsize=(16,4))

图 11-5 是一个功能性的但难以阅读的绘图。可以观察到女性奖项分布的趋势,但图中存在许多问题。让我们利用 Matplotlib 和 pandas 卓越的灵活性来解决这些问题。

dpj2 1105

图 11-5. 按年份和性别分的奖项

我们需要做的第一件事是减少 x 轴标签的数量。默认情况下,Matplotlib 将为每个条形图或条形图组标记标签,在我们的百年奖项中会创建混乱的标签。我们需要的是根据需要稀疏化轴标签的能力。在 Matplotlib 中有多种方法可以做到这一点;我将展示我发现最可靠的方法。这是您将要重复使用的类型,因此将其放入专用函数中是有意义的。示例 11-3 展示了一个函数,用于减少我们 x 轴上的刻度。

示例 11-3. 减少 x 轴标签的数量
def thin_xticks(ax, tick_gap=10, rotation=45):
    """ Thin x-ticks and adjust rotation """
    ticks = ax.xaxis.get_ticklocs() ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
    ticklabels = [l.get_text()
                  for l in ax.xaxis.get_ticklabels()] ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
    # Sets the new tick locations and labels at an interval
    # of tick_gap (default +10+):
    ax.xaxis.set_ticks(ticks[::tick_gap])
    ax.xaxis.set_ticklabels(ticklabels[::tick_gap],
                            rotation=rotation) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
    ax.figure.show()

1

获取当前条形图每个条或条组的 x 轴刻度位置和标签。

2

旋转标签以提高可读性,默认情况下是向上倾斜的对角线。

除了需要减少刻度的数量外,图 11-5 的 x 轴有一个不连续的范围,在第二次世界大战期间的 1939 年至 1945 年之间没有诺贝尔奖颁发。我们希望看到这样的间隙,因此需要手动设置 x 轴范围,以包含从诺贝尔奖开始到现在的所有年份。

当前未堆叠的组大小使用自动年份索引:

by_year_gender = df.groupby(['year', 'gender'])
by_year_gender.size().unstack()
Out:
gender  female  male
year
1901       NaN     6.0
1902       NaN     7.0
...
2014         2.0    11.0
[111 rows x 2 columns]

为了查看奖项分布中的任何间隙,我们只需用包含全年份范围的新 Series 重新索引即可:

new_index = pd.Index(np.arange(1901, 2015), name='year') ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
by_year_gender = df.groupby(['year','gender'])
year_gen_sz = by_year_gender.size().unstack()
  .reindex(new_index) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)

1

这里我们创建一个名为year的全范围索引,涵盖所有诺贝尔奖年份。

2

我们用新的连续索引替换我们的不连续索引。

图 11-5 的另一个问题是过多的条形图。虽然我们确实看到男性和女性条并排,但看起来混乱,还有混叠伪影。最好拥有专门的男性和女性图,但堆叠起来以便于轻松比较。我们可以使用我们在 “Axes and Subplots” 中看到的 subplot 方法,使用 pandas 数据但使用我们的 Matplotlib 知识自定义绘图。示例 11-4 展示了如何做到这一点,生成了 图 11-6 中的绘图。

示例 11-4. 按年份堆叠的性别奖项
new_index = pd.Index(np.arange(1901, 2015), name='year')
by_year_gender = df.groupby(['year','gender'])

year_gen_sz = by_year_gender.size().unstack().reindex(new_index)

fig, axes = plt.subplots(nrows=2, ncols=1, ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
            sharex=True, sharey=True, figsize=(16, 8)) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)

ax_f = axes[0]
ax_m = axes[1]

fig.suptitle('Nobel Prize-winners by gender', fontsize=16)

ax_f.bar(year_gen_sz.index, year_gen_sz.female) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
ax_f.set_ylabel('Female winners')

ax_m.bar(year_gen_sz.index, year_gen_sz.male)
ax_m.set_ylabel('Male winners')

ax_m.set_xlabel('Year')

1

创建了两个轴,分别在二行一列的网格上。

2

我们将共享 x 轴和 y 轴,这将使得两个图之间的比较更加合理。

3

我们为轴的条形图 (bar) 方法提供了连续的年份索引和未堆叠的性别列。

通过我们对性别分布的调查,我们得出结论存在巨大的差异,但正如图 11-6 所示,近年来有所改善。此外,经济学作为一个离群值,在科学领域的差异最大。考虑到女性获奖者数量相对较少,在这里没有太多可以看到的东西。

dpj2 1106

图 11-6. 按年份和性别分布的奖项,分布在两个堆叠的坐标轴上

现在让我们来看看奖项赢得的国家趋势,看看是否有任何有趣的可视化信息。

国家趋势

在查看国家趋势的显而易见的起点是绘制奖项获得者的绝对数量。这在 pandas 的一行中很容易实现,这里为了便于阅读分开了:

df.groupby('country').size().order(ascending=False)
        .plot(kind='bar', figsize=(12,4))

这产生了图 11-7,显示美国占据了奖项的主导地位。

奖品的绝对数量将有利于人口较多的国家。让我们看看更公平的比较,可视化每人均奖项。

dpj2 1107

图 11-7. 各国绝对奖项数目

每人均获奖者

奖项获得者的绝对数量将有利于较大的国家,这引发了一个问题,如果我们考虑到人口大小,这些数字如何叠加?为了测试每人均奖项的获得情况,我们需要将绝对奖项数量除以人口大小。在“获取诺贝尔数据可视化的国家数据”中,我们从网络上下载了一些国家数据,并将其存储为 JSON 文件。现在让我们检索它,并用它来制作相对于人口大小的奖项的图表。

首先,让我们获取国家组的大小,以国家名称为索引标签:

nat_group = df.groupby('country')
ngsz = nat_group.size()
ngsz.index
Out:
Index([u'Argentina', u'Australia', u'Austria', u'Azerbaijan',...])

现在让我们将我们的国家数据加载到一个数据框中,并回顾它包含的数据:

df_countries = pd.read_json('data/winning_country_data.json',\
                            orient='index')

df_countries.loc['Japan'] # countries indexed by name

Out:
gini                   38.1
name                  Japan
alpha3Code              JPN
area               377930.0
latlng        [36.0, 138.0]
capital               Tokyo
population        127080000
Name: Japan, dtype: object

我们的国家数据集已经索引到其 name 列。如果我们向其添加 ngsz 国家组大小系列,该系列也具有国家名称索引,则两者将根据共享的索引组合,为我们的国家数据添加一个新的 nobel_wins 列。然后,我们可以使用这一新列将其除以人口大小,创建一个 nobel_wins_per_capita

df_countries = df_countries.set_index('name')
df_countries['nobel_wins'] = ngsz
df_countries['nobel_wins_per_capita'] =\
    df_countries.nobel_wins / df_countries.population

现在,我们只需按照 df_countries 数据框的新 nobel_wins_per_cap 列进行排序,并绘制每人均诺贝尔奖的图表,生成图 11-8:

df.countries.sort_values(by='nobel_wins_per_capita',\
    ascending=False).nobel_per_capita.plot(kind='bar',\
    figsize=(12, 4))

dpj2 1108

图 11-8. 每人均国家奖项数目

这显示加勒比海岛国圣卢西亚获得了第一名。作为诺贝尔奖获得者诗人德里克·沃尔科特的故乡,其 17.5 万人口使其每人均诺贝尔奖数目高达。

让我们通过过滤那些获得超过两次诺贝尔奖的国家,来看看情况如何叠加:

df_countries[df_countries.nobel_wins > 2]\
        .sort_values(by='nobel_wins_per_capita', ascending=False)\
        .nobel_wins_per_capita.plot(kind='bar')

图 11-9 中的结果显示,斯堪的纳维亚国家和瑞士的奖项数量超过了其应有的份额。

dpj2 1109

图 11-9. 人均国家奖项数量,过滤为三项或更多获奖

将国家奖项计数的度量方式从绝对值改为人均值会产生很大的差异。现在让我们稍微细化一下我们的搜索,并专注于奖项类别,寻找其中的有趣信息。

按类别分的奖项

让我们深入了解一下绝对奖项数据,并查看按类别获胜的情况。这将需要按国家和类别列进行分组,获取这些组的大小,展开生成的 Series,然后绘制生成的 DataFrame 的列。首先,我们通过国家和类别列获取我们的类别与国家组大小:

nat_cat_sz = df.groupby(['country', 'category']).size()
.unstack()
nat_cat_sz
Out:
category     Chemistry  Economics  Literature  Peace  \...
country
Argentina            1        NaN         NaN      2
Australia          NaN          1           1    NaN
Austria              3          1           1      2
Azerbaijan         NaN        NaN         NaN    NaN
Bangladesh         NaN        NaN         NaN      1

然后我们使用 nat_cat_sz DataFrame 为六个诺贝尔奖类别生成子图:

COL_NUM = 2
ROW_NUM = 3

fig, axes = plt.subplots(ROW_NUM, COL_NUM, figsize=(12,12))

for i, (label, col) in enumerate(nat_cat_sz.items()): ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
    ax = axes[i//COL_NUM, i%COL_NUM] ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
    col = col.order(ascending=False)[:10] ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
    col = col.sort_values(ascending=True) ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/4.png)
    col.plot(kind='barh', ax=ax)
    ax.set_title(label)

plt.tight_layout() ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/5.png)

1

items 返回一个以 (列标签,列) 元组形式的 DataFrame 列的迭代器。

2

Python 3 引入了方便的 整数除法 运算符 //,它返回除法的向下取整的整数值。

3

order 通过首先制作一个副本来对列的 Series 进行排序。它相当于 sort(inplace=False)

4

将最大的 10 个国家去掉后,我们现在将顺序反转以制作条形图,该图自底向上绘制,将最大的国家放在顶部。

5

tight_layout 应该可以防止子图之间的标签重叠。如果您在使用 tight_layout 时遇到任何问题,请参阅 “标题和轴标签” 的末尾。

这会生成 图 11-10 中的图表。

图 11-10 中的一些有趣信息是,美国在经济奖项上的压倒性统治反映了二战后的经济共识,而法国在文学奖项上的领导地位。

dpj2 1110

图 11-10. 按国家和类别的奖项

奖项分配的历史趋势

现在我们知道了按国家的奖项统计数据,是否有任何有趣的历史趋势与奖项分配有关?让我们通过一些折线图来探索一下。

首先,让我们将默认字体大小增加到 20 点,以使图表标签更清晰可读:

plt.rcParams['font.size'] = 20

我们将要查看的是按年份和国家的奖项分布情况,所以我们需要基于这两列创建一个新的未堆叠的 DataFrame。与以前一样,我们添加一个 new_index 来提供连续的年份:

new_index = pd.Index(np.arange(1901, 2015), name='year')

by_year_nat_sz = df.groupby(['year', 'country'])\
    .size().unstack().reindex(new_index)

我们感兴趣的趋势是各国诺贝尔奖的累积总和。我们可以进一步探索各个类别的趋势,但现在我们将查看所有类别的总数。pandas 提供了一个方便的cumsum方法来做到这一点。让我们取美国列并绘制它:

by_year_nat_sz['United States'].cumsum().plot()

这生成了图 11-11 中的图表。

dpj2 1111

图 11-11. 美国奖项获得者随时间的累积总和

线图中的间隙是NaN字段,即美国在某些年份没有获奖的年份。cumsum算法在这里返回NaN。让我们将这些填充为零以去除这些间隙:

by_year_nat_sz['United States'].fillna(0)
    .cumsum().plot()

这生成了更干净的图表,显示在图 11-12 中。

dpj2 1112

图 11-12. 美国奖项获得者随时间的累积总和

让我们比较美国的获奖率与世界其他地区的获奖率:

by_year_nat_sz = df.groupby(['year', 'country'])
    .size().unstack().fillna(0)

not_US = by_year_nat_sz.columns.tolist() ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
not_US.remove('United States')

by_year_nat_sz['Not US'] = by_year_nat_sz[not_US].sum(axis=1) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
ax = by_year_nat_sz[['United States', 'Not US']]\
    .cumsum().plot(style=['-', '--']) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)

1

获取国家列名称列表并移除美国。

2

使用我们的非美国国家名称列表创建一个'Not_US'列,对not_US列表中所有国家的奖项总和进行求和。

3

在 pandas 绘图中,默认情况下,线条是有颜色的。为了在印刷书籍中区分它们,我们可以使用style参数使一条线变为实线(-),另一条线变为虚线(--),使用 Matplotlib 的线条样式(详见文档)。

此代码生成了图 11-13 中显示的图表。

dpj2 1113

图 11-13. 美国与世界其他地区奖项总和比较

'Not_US'的赢取数量显示出奖项多年来稳定增长的情况下,美国在二战结束后显示出急剧增长。让我们进一步调查一下,关注北美洲、欧洲和亚洲两到三个最大的赢家的地区差异:

by_year_nat_sz = df.groupby(['year', 'country'])\
    .size().unstack().reindex(new_index).fillna(0)

regions =  ![1
    {'label':'N. America',
      'countries':['United States', 'Canada']},
    {'label':'Europe',
     'countries':['United Kingdom', 'Germany', 'France']},
    {'label':'Asia',
     'countries':['Japan', 'Russia', 'India']}
]

for region in regions: ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
    by_year_nat_sz[region['label']] =\
        by_year_nat_sz[region['countries']].sum(axis=1)

by_year_nat_sz[[r['label'] for r in regions]].cumsum()\
  .plot(style=['-', '--', '-.']) # solid, dashed, dash-dotted line style ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)

1

通过选择三大洲中两到三个最大的赢家,我们创建了我们的大陆国家列表。

2

创建一个带有每个regions列表中的dict的区域标签的新列,总结其countries成员。

3

绘制所有新区域列的累积总和。

这给我们提供了图 11-14 中的图表。亚洲的奖项获得数量在多年来略有增加,但值得注意的主要是北美洲在 1940 年代中期前后的巨大增长,超过了在 1980 年代中期左右奖项总数下降的欧洲。

dpj2 1114

图 11-14. 按地区的历史奖项趋势

让我们通过总结 16 位最大获奖者(排除美国的离群值)的奖金比例来扩展先前的国家情节的详细信息:

COL_NUM = 4
ROW_NUM = 4

by_nat_sz = df.groupby('country').size()
by_nat_sz.sort_values(ascending=False,\
    inplace=True)  ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)

fig, axes = plt.subplots(COL_NUM, ROW_NUM,\ ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
    sharex=True, sharey=True, ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
    figsize=(12,12))

for i, nat in enumerate(by_nat.index[1:17]): ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
    ax = axes[i/COL_NUM, i%ROW_NUM]
    by_year_nat_sz[nat].cumsum().plot(ax=ax) ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/4.png)
    ax.set_title(nat)

1

将我们的国家组按获奖总数从高到低排序。

2

获取具有共享 x 和 y 轴的 4×4 轴网格,用于归一化比较。

3

枚举从第二行(1)开始的排序索引,排除美国(0)。

4

选择 nat 国家名称列,并在网格轴 ax 上绘制其奖励的累积和。

这将生成 图 11-15,显示了一些历史上获奖率上升的国家,如日本,澳大利亚和以色列,而其他国家则趋于平稳。

dpj2 1115

图 11-15. 美国之后排名前 16 位的国家获奖率

另一种总结国家奖励率随时间变化的好方法是使用热图,并按十年将总数划分。 seaborn 库提供了一个很好的热图。 让我们导入它,并使用其 set 方法通过缩放来增加其标签的字体大小:

import seaborn as sns

sns.set(font_scale = 1.3)

数据分割成块也称为分箱,因为它创建数据。 pandas 有一个方便的 cut 方法来完成这个任务,它接受一列连续值——在我们的情况下是诺贝尔奖年份——并返回指定大小的范围。 您可以将 DataFrame 的 groupby 方法与 cut 的结果一起使用,并将其按索引值范围分组。 以下代码生成 图 11-16:

bins = np.arange(df.year.min(), df.year.max(), 10) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)

by_year_nat_binned = df.groupby('country',\
    [pd.cut(df.year, bins, precision=0)])\ ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
    .size().unstack().fillna(0)

plt.figure(figsize=(8, 8))

sns.heatmap(\
  by_year_nat_binned[by_year_nat_binned.sum(axis=1) > 2],\ ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
  cmap='rocket_r') ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/4.png)

1

从 1901 年开始,获取我们的十年期间的区间(1901 年,1911 年,1921 年...)。

2

使用 bins 范围将我们的诺贝尔奖年份划分为十年,精度设置为 0,以给出整数年份。

3

在制作热图之前,我们先筛选出拥有两个以上诺贝尔奖的国家。

4

我们使用连续的 rocket_r 热图来突出显示差异。 查看 seaborn 文档 中的所有 pandas 颜色调色板。

图 11-16 捕捉到一些有趣的趋势,比如俄罗斯在 1950 年代的短暂繁荣,到了 1980 年代就消退了。

现在我们已经调查了诺贝尔奖的国家,让我们将注意力转向个体获奖者。 使用手头的数据,我们能发现他们有什么有趣的事情吗?

dpj2 1116

图 11-16. 各国诺贝尔奖获得情况按十年统计

获奖者的年龄和预期寿命

我们有所有获奖者的出生日期和 559 位获奖者的逝世日期。结合他们获奖的年份,我们有相当多的个人数据可以挖掘。让我们调查获奖者年龄的分布,并试图了解他们的长寿。

颁奖时的年龄

在第九章中,我们通过将获奖者的年龄减去他们的获奖年份,为诺贝尔奖数据集添加了一个 'award_age' 列。使用 pandas 的直方图绘制来评估这个分布是一个快速且简单的胜利:

df['award_age'].hist(bins=20)

这里我们要求将年龄数据分成 20 个箱子。这产生了 Figure 11-17,显示 60 年代初是获奖的一个黄金期,如果你到了 100 岁还没有获奖,可能就不会发生了。请注意 20 岁左右的异常值,这是和平奖的 17 岁获奖者马拉拉·尤萨夫扎伊

dpj2 1117

图 11-17. 颁奖时年龄的分布

我们可以使用 seaborn 的 displot 来更好地了解分布情况,添加一个核密度估计(KDE)^(2)到直方图中。以下一行代码生成 Figure 11-18,显示我们的黄金年龄大约是 60 岁:

sns.displot(df['award_age'], kde=True, height=4, aspect=2)

dpj2 1118

图 11-18. 颁奖时年龄的分布并叠加了 KDE

箱线图是可视化连续数据的一种好方法,显示四分位数,第一和第三个四分位数标记箱子的边缘,第二个四分位数(或中位数)标记在箱子内的线。通常,如图 Figure 11-19,水平的端线(称为须线端点)表示数据的最大值和最小值。让我们使用 seaborn 的箱线图,并按性别划分奖项:

sns.boxplot(df, x='gender', y='award_age')

这产生了 Figure 11-19,显示按性别的分布相似,女性的平均年龄略低。请注意,由于女性获奖者较少,她们的统计数据更不确定。

dpj2 1119

图 11-19. 按性别分布的获奖者年龄

seaborn 的小提琴图结合了传统的箱线图和核密度估计,以更精细的方式显示年龄和性别的分布情况。以下代码生成 Figure 11-20:

sns.violinplot(data=df, x='gender', y='award_age')

dpj2 1120

图 11-20. 小提琴图显示按性别分布的奖项年龄

获奖者的预期寿命

现在让我们看看诺贝尔奖获得者的长寿情况,通过他们的出生日期减去逝世日期。我们将这些数据存储在一个新的 'age_at_death' 列中:

df['age_at_death'] = (df.date_of_death - df.date_of_birth)\
                     .dt.days/365 ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)

1

datetime64 数据可以进行合理的加减操作,生成一个 pandas timedelta 列。我们可以使用它的 dt 方法获取以天为单位的间隔,将其除以 365 得到浮点数的死亡年龄。

我们复制 'age_at_death' 列,^(3) 删除所有空的 NaN 行。这可以用来制作 Figure 11-21 中显示的直方图和 KDE:

age_at_death = df[df.age_at_death.notnull()].age_at_death ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)

sns.displot(age_at_death, bins=40, kde=True, aspect=2, height=4)

1

删除所有的 NaN 以清理数据并减少绘图错误(例如,distplot 在有 NaN 的情况下会失败)。

dpj2 1121

Figure 11-21. 诺贝尔奖获得者的寿命期望

Figure 11-21 展示了诺贝尔奖获得者是一群寿命异常长的人,平均寿命在 80 岁左右。这更加令人印象深刻,因为大多数获奖者都是男性,在一般人口中男性的平均寿命显著较低^(4)。导致这种长寿的一个因素是我们早些看到的选择偏差。诺贝尔奖获得者通常直到他们五六十岁才受到尊敬,这排除了那些没有机会被认可的亚群体,从而提高了长寿的数据。

Figure 11-21 展示了一些长寿者在获奖者中。让我们找找看:

df[df.age_at_death > 100][['name', 'category', 'year']]
Out:
                     name                category  year
101          Ronald Coase               Economics  1991
328   Rita Levi-Montalcini  Physiology or Medicine 1986

现在让我们叠加几个 KDE 来展示男性和女性获奖者的死亡率差异:

df_temp = df_temp[df.age_at_death.notnull()] ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
sns.kdeplot(df_temp[df_temp.gender == 'male']
    .age_at_death, shade=True, label='male')
sns.kdeplot(df_temp[df_temp.gender == 'female']
    .age_at_death, shade=True, label='female')

plt.legend()

1

创建一个只包含有效 'age_at_death' 字段的 DataFrame。

这产生了 Figure 11-22,尽管女性获奖者数量较少且分布较为平坦,显示男性和女性的平均值接近。相比一般人群,女性诺贝尔奖获得者似乎活得相对较短。

dpj2 1122

Figure 11-22. 获奖者的寿命期望值按性别划分

一张小提琴图提供了另一种视角,如 Figure 11-23 所示:

sns.violinplot(data=df, x='gender', y='age_at_death',\
               aspect=2, height=4)

dpj2 1123

Figure 11-23. 获奖者的寿命期望值按性别划分

随着时间的推移,寿命期望值增加

让我们通过看看我们的诺贝尔奖获得者的出生日期和他们的寿命期望之间是否有相关性,做一点历史人口统计分析。我们将使用 seaborn 的 lmplot 之一提供散点图和带有置信区间的线性拟合(参见 “seaborn”):

df_temp = df[df.age_at_death.notnull()] ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
data = pd.DataFrame( ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
    {'age at death':df_temp.age_at_death,
     'date of birth':df_temp.date_of_birth.dt.year})
sns.lmplot(data=data, x='date of birth', y='age at death',
  height=6, aspect=1.5)

1

创建一个临时 DataFrame,删除所有没有 'age_at_death' 字段的行。

2

创建一个新的 DataFrame,只包含来自精炼的 df_temp 的两列感兴趣的内容。我们只从 date_of_birth 中获取年份,使用其 dt accessor

这产生了图 11-24,显示奖项期间预期寿命增加了约十年。

dpj2 1124

图 11-24. 出生日期与去世年龄的相关性

诺贝尔流亡者

在清理我们的诺贝尔奖数据集时(见第九章),我们发现有重复条目记录了获奖者出生地和获奖时的国家。我们保留了这些信息,共有 104 名获奖者,其获奖时的国家与出生国不同。这其中是否有故事?

从获奖者的出生国到其所选国的移动模式的良好可视化方法是使用热图来显示所有 born_in/country 对。以下代码生成了图 11-25 中的热图:

by_bornin_nat = df[df.born_in.notnull()].groupby(\ ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
    ['born_in', 'country']).size().unstack()
by_bornin_nat.index.name = 'Born in' ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
by_bornin_nat.columns.name = 'Moved to'
plt.figure(figsize=(12, 12))

ax = sns.heatmap(by_bornin_nat, vmin=0, vmax=8, cmap="crest",\ ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
                 linewidth=0.5)
ax.set_title('The Nobel Diaspora')

1

选择所有具有 'born_in' 字段的行,并根据此字段和国家列进行分组。

2

我们重新命名行索引和列名,使它们更具描述性。

3

seaborn 的 heatmap 尝试设置正确的数据边界,但在这种情况下,我们必须手动调整限制 (vminvmax) 以查看所有单元格。

dpj2 1125

图 11-25. 诺贝尔奖流亡现象

图 11-25 显示了一些有趣的模式,讲述了迫害和庇护的故事。首先,美国是重新安置的诺贝尔奖获得者的主要接收国,其次是英国。请注意,两者(除了来自加拿大的跨国移民)的最大派系都来自德国。意大利、匈牙利和奥地利是下一个最大的群体。检查这些群体中的个体表明,大多数是由于二战前纳粹反犹太少数民族政权的崛起和对犹太人的迫害而被迫流离失所的。

举个例子,所有四位从德国移居到英国的诺贝尔奖获得者都是具有犹太血统的德国研究科学家,他们是对纳粹兴起作出反应而迁移:

df[(df.born_in == 'Germany') & (df.country == 'United Kingdom')]
    [['name', 'date_of_birth', 'category']]

Out:
                    name date_of_birth                category
119  Ernst Boris Chain    1906-06-19  Physiology or Medicine
484   Hans Adolf Krebs    1900-08-25  Physiology or Medicine
486           Max Born    1882-12-11                 Physics
503       Bernard Katz    1911-03-26  Physiology or Medicine

恩斯特·恩·查恩(Ernst Chain)开创了盘尼西林的工业生产。汉斯·克雷布斯(Hans Krebs)发现了克雷布斯循环,这是生物化学中最重要的发现之一,调节细胞的能量产生。马克斯·玻恩(Max Born)是量子力学的先驱之一,伯纳德·卡茨(Bernard Katz)揭示了神经元突触连接的基本特性。

在获奖的移民中有许多如此显赫的名字。一个有趣的发现是参与著名Kindertransport行动的奖项获得者数量,这是第二次世界大战爆发前九个月进行的一次组织营救行动,将德国、奥地利、捷克斯洛伐克和波兰的一万名犹太儿童运送到英国。这些儿童中有四人后来获得了诺贝尔奖。

总结

在这一章中,我们探索了我们的诺贝尔奖数据集,分析了性别、类别、国家和奖励年份等关键字段,寻找可以讲述或视觉化的有趣趋势和故事。我们使用了大量的 Matplotlib(通过 pandas)和 seaborn 的图表,从基本的条形图到更复杂的统计摘要,如小提琴图和热图。精通这些工具以及 Python 图表库中的其他工具,将使您能够快速了解数据集的感觉,这是围绕它们构建可视化的先决条件。我们在数据中找到了足够多的故事,建议进行网络可视化。在下一章中,我们将想象并设计一个诺贝尔奖获奖者的可视化作品,挑选出本章获得的宝贵经验。

^(1) 据传,我个人询问过的或者在演讲观众中询问过的人,没有一个知道物理学领域另一位女性诺贝尔奖得主的名字。

^(2) 详细信息请参阅Wikipedia。基本上,数据经过平滑处理并推导出概率密度函数。

^(3) 在从天数推导年份时,我们忽略了闰年和其他微妙的复杂因素。

^(4) 根据国家的不同,这大约是五到六年时间。请参阅Our World in Data获取一些统计数据。

第十三章:第 IV 部分:数据交付

在本书的这一部分中,我们将看到如何将我们选择的诺贝尔奖数据集交付给浏览器,在其中 JavaScript 和 D3 将把它转化为一个引人入胜的、交互式的可视化(参见图 IV-1)。

使用 Python 这样的通用库的好处是,您可以轻松地在几行简洁而令人印象深刻的代码中构建 Web 服务器,就像使用强大的数据处理库来挖掘数据一样容易。

我们工具链中的关键服务器工具是 Flask,这是 Python 中强大但轻量级的 Web 框架。在第十二章中,我们将看到如何静态地提供您的数据(提供系统文件),以及动态地提供数据,通常是作为请求中指定的数据库选择。在第十三章中,我们将看到两个基于 Flask 的库如何使创建 RESTful Web API 只需几行 Python 代码即可完成。

dpj2 p426

图 IV-1. 数据交付
提示

您可以在书的 GitHub 仓库找到本书这部分的代码。

第十二章:传递数据

第六章展示了如何使用网络爬虫从网上获取你感兴趣的数据。我们使用 Scrapy 获取了一个诺贝尔奖获得者的数据集,然后在第九章和第十一章中使用 pandas 对诺贝尔奖数据集进行了清洗和探索。

本章将向你展示如何从 Python 服务器静态或动态地将数据传递到客户端浏览器的 JavaScript,以我们的诺贝尔奖数据集为例。这些数据以 JSON 格式存储,包含一个诺贝尔奖获得者对象列表,就像在例子 12-1 中所示的那样。

例子 12-1。我们的诺贝尔奖 JSON 数据,被爬取然后清洗
[
  {
    "category": "Physiology or Medicine",
    "country": "Argentina",
    "date_of_birth": "1927-10-08T00:00:00.000Z",
    "date_of_death": "2002-03-24T00:00:00.000Z",
    "gender": "male",
    "link": "http:\/\/en.wikipedia.org\/wiki\/C%C3%A9sar_Milstein",
    "name": "C\u00e9sar Milstein",
    "place_of_birth": "Bah\u00eda Blanca ,  Argentina",
    "place_of_death": "Cambridge , England",
    "text": "C\u00e9sar Milstein , Physiology or Medicine, 1984",
    "year": 1984,
    "award_age": 57,
    "born_in": "",
    "bio_image": "full/6bf65058d573e07b72231407842018afc98fd3ea.jpg",
    "mini_bio": "<p><b>César Milstein</b>, <a href='http://en.w..."
  }
  ['...']
]

就像本书的其余部分一样,重点是尽量减少 Web 开发的量,这样你就可以专注于在 JavaScript 中构建 Web 可视化。

提示

一个好的经验法则是尽量使用 Python 进行尽可能多的数据操作——这比在 JavaScript 中进行等效操作要少痛苦得多。由此而来,传递的数据应尽可能接近它将被消费的形式(即对于 D3,这通常是一个包含对象的 JSON 数组,就像我们在第九章中生成的那样)。

提供数据

你需要一个 Web 服务器来处理浏览器发来的 HTTP 请求,用于构建网页的初始静态 HTML 和 CSS 文件,以及任何后续的 AJAX 请求数据。在开发过程中,该服务器通常运行在 localhost 的某个端口上(在大多数系统上,这个 IP 地址是 127.0.0.1)。按照惯例,index.xhtml HTML 文件用于初始化网站或我们的情况下,构成我们 Web 可视化的单页面应用(SPA)

使用单行服务器为可视化原型设计和构思提供服务可能是合适的,但无法控制基本的服务器功能,如 URL 路由或动态模板的使用。幸运的是,Python 有一个很棒的小型 Web 服务器,提供了 Web 可视化器所需的所有功能,而不会牺牲我们旨在最大程度减少 Python 处理数据与 JavaScript 可视化杰作之间模板代码的目标。Flask 就是这个小型 Web 服务器,是我们最佳工具链中的一个值得推荐的补充。

组织你的 Flask 文件

如何组织项目文件是那些经常被忽视的非常有用的信息之一,可能是因为在教程中很容易变得主观化,而且最终归根结底,这是个人偏好。尽管如此,良好的文件组织确实能带来很多好处,尤其是当你开始合作时。

图 12-1 提供了一个大致的想法,即在从使用一个标记为basic的单行服务器的基本数据可视化 JavaScript 原型转移到使用一个标记为basic+的更复杂项目,再到一个典型的简单 Flask 设置标记为flask_project时,应该将文件放在哪里。

dpj2 1201

图 12-1. 组织您的服务器项目文件

文件组织的关键是一致性。在过程记忆中,文件的位置对帮助是极大的。

使用 Flask 提供数据

如果您正在使用 Python 的 Anaconda 包(请参阅第一章),那么 Flask 已经可用。否则,简单的pip安装应该可以让其可用:

$ pip install Flask

手头有了 Flask 模块,我们可以用几行代码设置一个服务器,用来提供一个通用的编程问候:

# server.py
from flask import Flask
app = Flask(__name__)

@app.route("/") ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
def hello():
    return "Hello World!"

if __name__ == "__main__":
    app.run(port=8000, debug=True) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)

1

Flask 路由允许您指导网络流量。这是根路由(即,http://localhost:8000)。

2

设置本地主机端口,服务器将在此端口上运行(默认为 5000)。在调试模式下,Flask 会向屏幕提供有用的日志记录,并在错误发生时提供基于浏览器的报告。

现在,只需转到包含nobel_viz.py的目录,并运行该模块:

$ python server.py
 * Serving Flask app 'server' (lazy loading)
 * Environment: production
   WARNING: This is a development server. Do not use it in a production deployment.
   Use a production WSGI server instead.
 * Debug mode: off
 * Running on http://127.0.0.1:8000/ (Press CTRL+C to quit)

现在,您可以转到您选择的网络浏览器,并查看在图 12-2 中显示的强调结果。

dpj2 1202

图 12-2. 一个简单的消息提供给浏览器

正如我们将在“使用 Flask API 提供动态数据”中看到的,Flask 路由的模式匹配使得轻松实现简单的 Web API 成为可能。还可以使用模板来生成动态网页,如图 12-4 所示。模板在组成基本静态 HTML 页面时非常有用,但通常会使用 JavaScript 构建可视化效果。在 JavaScript 中配置可视化效果时,服务器的主要工作(除了提供种子过程所需的静态文件之外)是通过 AJAX 请求动态地处理数据。

dpj2 1204

图 12-4. (1) 使用一个message变量创建一个网页模板,然后 (2) 将其提供给浏览器

Flask 完全能够提供完整的网站,具有强大的 HTML 模板化功能,用于模块化大型站点的蓝图和支持常见使用模式的插件和扩展。Flask 用户指南是学习更多内容的良好起点,API 的具体信息可以在指南的这一小节中找到。大多数 Web 可视化的单页面应用不需要大量的服务器端功能来交付必要的静态文件。我们对 Flask 的关键兴趣在于其提供简单高效的数据服务器,几行 Python 代码即可实现强大的 RESTful Web API。但在涉足数据 API 之前,让我们先看看如何交付和使用基于文件的数据资产,如 JSON 和 CSV 文件。

交付数据文件

许多不需要动态配置数据开销的网站选择以静态形式提供它们的数据,这基本上意味着所有 HTML 文件和关键的数据(通常是 JSON 或 CSV 格式)作为文件存在于服务器文件系统中,可以直接交付,例如,无需调用数据库。

静态页面易于缓存,这意味着它们的交付速度可以更快。它还可能更安全,因为那些数据库调用可以是恶意黑客的常见攻击向量(例如,注入攻击)。为了获得这种增加的速度和安全性所付出的代价是灵活性的丧失。限制在预组装页面集合上意味着禁止可能需要多变数据组合的用户交互。

对于初学者的数据可视化者,提供静态数据具有吸引力。您可以轻松创建一个独立的项目,无需 Web API,并且可以将您的工作(进行中)作为一个包含 HTML、CSS 和 JSON 文件的单个文件夹交付。

使用静态文件进行数据驱动的 Web 可视化的最简单示例可能是在https://bl.ocks.org/mbostock上看到的许多酷炫的 D3 示例。它们遵循与我们在“具有占位符的基本页面”中讨论的基本页面类似的结构。

虽然示例中使用<script><style>标签将 JavaScript 和 CSS 嵌入 HTML 页面,但我建议将 CSS 和 JavaScript 保留在单独的文件中,这样可以获得良好的格式感知编辑器和更容易的调试。

示例 12-2 展示了一个包含<h2><div>数据占位符以及加载本地script.js文件的index.xhtml基本页面。由于我们只设置了font-family样式,所以我们将在页面中内联 CSS。在data子目录中使用我们的nobel_winners.json数据集,这给我们带来以下文件结构:

viz
├── data
│   └── nobel_winners.json
├── index.xhtml
└── script.js
示例 12-2. 带有数据占位符的基本 HTML 页面
<!DOCTYPE html>
<meta charset="utf-8">

<style>
  body{ font-family: sans-serif; }
</style>

<h2 id='data-title'></h2>
<div id='data'>
    <pre></pre>
</div>

<script src="lib/d3.v7.min.js"></script>
<script src="script.js"></script>

这些示例的静态数据文件包含在一个单独的 JSON 文件(nobel_winners.json),位于data子目录中。消费这些数据需要通过 JavaScript 进行一个AJAX调用到我们的服务器。D3 提供了方便的库来进行 AJAX 调用,D3 的特定格式的jsoncsvtsv方法对于 Web 可视化程序非常方便。

示例 12-3 展示了如何使用 D3 的json方法通过回调函数加载数据。在幕后,D3 使用 JavaScript 的Fetch API来获取数据。这返回一个 JavaScript 的Promise,可以使用其then方法解析,返回数据,除非发生错误。

示例 12-3. 使用 D3 的json方法加载数据
d3.json("data/nobel_winners_cleaned.json")
  .then((data) => {
  d3.select("h2#data-title").text("All the Nobel-winners");
  d3.select("div#data pre").html(JSON.stringify(data, null, 4)); ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
});

1

JavaScript 的JSON.stringify方法是一种方便的方法,可以使 JavaScript 对象输出漂亮。在这里,我们插入一些空格以使输出缩进四个空格。

如果你在viz目录中运行一个单行服务器(例如,python -m http.server)并在浏览器中打开本地主机页面,你应该会看到类似于图 12-5,表明数据已成功传递给 JavaScript,准备好被可视化。

dpj2 1205

图 12-5. 将 JSON 传递给浏览器

我们正在使用的nobel_winners.json数据集并不是特别大,但是如果我们开始添加传记正文或其他文本数据,它很容易会增长到超出浏览器带宽限制并且使用户等待变得不舒服的程度。限制加载时间的一种策略是根据其中一个维度将数据分解为子集。对于我们的数据来说,显而易见的方法是按国家存储获奖者。几行 pandas 代码就可以创建一个合适的data目录:

import pandas as pd

df_winners = pd.read_json('data/nobel_winners.json')

for name, group in df_winners.groupby('country'): ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
    group.to_json('data/winners_by_country' + name + '.json',\
                  orient='records')

1

将获奖者 DataFrame 按country分组,并迭代组名和成员。

这应该给我们一个winners_by_country data子目录:

$ ls data/winners_by_country
Argentina.json  Azerbaijan.json      Canada.json
Colombia.json   Czech Republic.json  Egypt.json  ...

现在,我们可以使用一个小小的定制函数按国家消费我们的数据:

let loadCountryWinnersJSON = function (country) {
    d3.json("data/winners_by_country/" + country + ".json")
      .then(function (data) {
        d3.select("h2#data-title").text(
          "All the Nobel-winners from " + country
        );
        d3.select("div#data pre").html(JSON.stringify(data, null, 4));
      })
      .catch((error) => console.log(error));
  };

以下函数调用将选择所有澳大利亚的诺贝尔奖获得者,生成图 12-6:

loadCountryWinnersJSON('Australia');

dpj2 1206

图 12-6. 按国家选择获奖者

对于正确的可视化,通过国家选择获奖者可以减少数据带宽和随后的延迟,但是如果我们想按年份或性别获取获奖者呢?每个维度(分类、时间等)的分割都需要自己的子目录,从而创建文件的混乱和所有相关的簿记工作。如果我们想对数据进行精细的请求(例如,自 2000 年以来所有美国的奖项获得者)怎么办?在这一点上,我们需要一个可以动态响应此类请求的数据服务器,通常由用户交互驱动。接下来的部分将向您展示如何开始使用 Flask 制作这样的服务器。

使用 Flask APIs 实现动态数据

通过 JSON 或 CSV 文件向网页传递数据是许多令人印象深刻的数据可视化示例的基础,也非常适合小型演示和原型。但是,在表单方面存在一些约束,最明显的是可以实际传递的数据集大小。随着数据集的增大和文件开始超过几兆字节,页面加载变慢,用户在旋转加载器的每次旋转时会越来越沮丧。对于大多数数据可视化,特别是仪表板或探索性图表,根据需要和响应用户界面生成的用户请求传递数据是有意义的。对于这种数据传递,一个小型数据服务器通常非常适合工作,并且 Python 的 Flask 拥有您制作这些内容所需的一切。

如果我们要动态地提供数据,我们需要某种 API 来让我们的 JavaScript 请求数据。

用 Flask 构建简单的数据 API

使用 Dataset(参见 “使用 Dataset 更轻松的 SQL”),我们可以轻松地为 SQL 数据库调整我们现有的服务器。在这里,我们为了方便使用 Dataset 和专用的 JSON 编码器(参见 示例 3-2),将 Python datatimes 转换为 JSON 友好的 ISO 字符串:

# server_sql.py
from flask import Flask, request, abort
import dataset
import json
import datetime

app = Flask(__name__)
db = dataset.connect('sqlite:///data/nobel_winners.db')

@app.route('/api/winners')
def get_country_data():
    print 'Request args: ' + str(dict(request.args))
    query_dict = {}
    for key in ['country', 'category', 'year']: ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
        arg = request.args.get(key) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
        if arg:
            query_dict[key] = arg

    winners = list(db['winners'].find(**query_dict)) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
    if winners:
        return dumps(winners)
    abort(404) # resource not found

class JSONDateTimeEncoder(json.JSONEncoder): ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/4.png)
    def default(self, obj):
        if isinstance(obj, (datetime.date, datetime.datetime)):
            return obj.isoformat()
        else:
            return json.JSONEncoder.default(self, obj)

def dumps(obj):
    return json.dumps(obj, cls=JSONDateTimeEncoder)

if __name__=='__main__':
    app.run(port=8000, debug=True)

1

限制我们数据库查询的键在这个列表中。

2

request.args 让我们能够访问请求的参数(例如,'?country=Australia&category=Chemistry')。

3

datasetfind 方法要求我们的参数字典使用 ** 解包(即 find(country='Australia', category='Literature'))。我们将迭代器转换为列表,以便进行序列化。

4

这是在 示例 3-2 中详细介绍的专用 JSON 编码器。

在启动服务器后,我们可以用 curl 测试这个小小的 API (python server_sql.py)。让我们获取所有日本的物理学奖获得者:

$ curl -d category=Physics -d country=Japan
  --get http://localhost:8000/api/

[{"index": 761, "category": "Physics", "country": "Japan",
"date_of_birth": "1907-01-23T00:00:00", "date_of_death": "1981-09-08T00:00:00",
"gender": "male", "link": "http://en.wikipedia.org/wiki/Hideki_Yukawa",
"name": "Hideki Yukawa", "place_of_birth": "Tokyo ,  Japan",
"place_of_death": "Kyoto ,  Japan", "text": "Hideki Yukawa , Physics, 1949",
"year": 1949, "award_age": 42}, {"index": 762, "category": "Physics",
"country": "Japan", "date_of_birth": "1906-03-31T00:00:00",
"date_of_death": "1979-07-08T00:00:00", "gender": "male", ... }]

现在您已经看到了开始创建简单 API 有多么容易。有很多方法可以扩展它,但是对于快速而肮脏的原型设计,这是一个非常方便的形式。

但是,如果您想要分页、认证以及诸如此类的复杂 RESTful API 所提供的其他功能呢?在下一章中,我们将看到如何将我们简单的数据 API 扩展为更强大和可扩展的东西,使用一些出色的 Python 库,如 marmalade。

使用静态或动态交付

何时使用静态或动态交付很大程度上取决于上下文,并且是一个不可避免的妥协。带宽在不同地区和设备上有所不同。例如,如果您正在开发一个可从农村环境中的智能手机访问的可视化效果,那么数据限制与内部数据应用程序在本地网络上运行时的情况大不相同。

用户体验是最终指南。如果在数据缓存的开始稍等片刻可以获得闪电般快速的 JavaScript 数据可视化,那么纯静态交付可能是答案。如果允许用户剪切和切片大型多变量数据集,则可能需要耐心等待。粗略的经验法则是,任何小于 200 KB 的数据集在纯静态交付下应该都没问题。随着数据量增加到兆字节及以上,您可能需要一个数据库驱动的 API 来获取数据。

概要

本章介绍了在 Web 服务器上静态数据传递文件的基础知识,以及动态数据传递,勾勒出基于简单 Flask 的 RESTful Web 服务器的基础。虽然 Flask 使得创建基本数据 API 变得非常轻松,但添加分页、选择性数据查询和 HTTP 动词的全套功能需要更多的工作。在本书的第一版中,我转向了一些现成的 Python RESTful 库,但这些库往往很快就会过时,可能是因为可以如此轻松地串联一些单一用途的 Python 库来实现相同的目标,并具有更高的灵活性。这也是学习这些工具的好方法,因此,建立一个正是这样的 RESTful API 是下一章的主题。

^(1) 迈克·博斯托克,D3 的创造者,是一个例子的强烈支持者。在这里有一个精彩的演讲,他强调了例子在 D3 成功中所起的作用。

第十三章:使用 Flask 构建 RESTful 数据

正如在“使用 Flask 构建简单数据 API”一节中所见,我们看到如何使用 Flask 和 Dataset 构建一个非常简单的数据 API。对于许多简单的数据可视化来说,这种快速而简陋的 API 是可以接受的,但随着数据需求变得更加复杂,有一个遵循一些检索和有时创建、更新和删除的惯例的 API 会更有帮助。(1) 在“使用 Python 从 Web API 消费数据”一章中,我们介绍了 Web API 的类型及为什么 RESTful(2) API 正在获得应有的重视。在本章中,我们将看到将几个 Flask 库组合成一个灵活的 RESTful API 是多么简单。

RESTful 作业工具

正如在“使用 Flask 构建简单数据 API”一节中所见,数据 API 的基础非常简单。它需要一个接受 HTTP 请求的服务器,例如 GET 请求用于检索数据或更高级的动词如 POST(用于添加)或 DELETE。这些请求位于诸如api/winners的路由上,然后由提供的函数处理。在这些函数中,数据从后端数据库中检索出来,可能会使用数据参数进行过滤(例如,像?category=comic&name=Groucho这样的字符串附加到 URL 调用中)。然后,这些数据需要以某种请求的格式返回或序列化,几乎总是基于 JSON。对于这种数据的往返,Flask/Python 生态系统提供了一些完美的库:

  • Flask 执行服务器工作

  • Flask SQLAlchemy是一个 Flask 扩展,将我们首选的 Python SQL 库与对象关系映射器(ORM)SQLAlchemy 集成。

  • Flask-Marshmallow是一个 Flask 扩展,添加了对marshmallow的支持,这是一个功能强大的 Python 对象序列化库。

您可以使用pip安装所需的扩展:

$ pip install Flask-SQLALchemy flask-marshmallow marshmallow-sqlalchemy

创建数据库

在“保存清理后的数据集”一节中,我们看到使用to_sql方法将 pandas DataFrame 存储到 SQL 中是多么简单。这是一种非常方便的存储 DataFrame 的方式,但生成的表缺少一个主键字段,这个字段唯一地指定表中的行。拥有主键是一个良好的习惯,而且对于通过我们的 Web API 创建或删除行来说几乎是必需的。因此,我们将通过另一种方式创建我们的 SQL 表。

首先,我们使用 SQLAlchemy 构建了一个 SQLite 数据库,并向获奖者表添加了主键 ID。

from sqlalchemy import Column, Integer, String, Text
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker

Base = declarative_base()

class Winner(Base):
    __tablename__ = 'winners'
    id = Column(Integer, primary_key=True) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
    category = Column(String)
    country = Column(String)
    date_of_birth = Column(String) # string form dates
    date_of_death = Column(String)
    # ...

# create SQLite database and start a session
engine = sqlalchemy.create_engine('sqlite:///data/nobel_winners_cleaned_api.db')
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()

1

我们指定了至关重要的主键来消除获奖者之间的歧义。

请注意,我们将日期存储为字符串以限制序列化日期时间对象时可能出现的任何问题。在提交行到数据库之前,我们将转换这些 DataFrame 列:

df['date_of_birth'] = df['date_of_birth'].astype(str)
df['date_of_death'] = dl['date_of_death'].astype(str)
df.date_of_birth
#0      1927-10-08
#4      1829-07-26
#5      1862-08-29
..

现在,我们可以迭代我们 DataFrame 的行,将它们作为字典记录添加到数据库中,然后将它们相对高效地作为一个事务提交:

for d in df_tosql.to_dict(orient='records'):
    session.add(Winner(**d))
session.commit()

有了我们成形完善的数据库,让我们看看如何使用 Flask 轻松地提供它。

Flask RESTful 数据服务器

这将是一个标准的 Flask 服务器,类似于 “使用 Flask 服务数据” 中看到的那个。首先,我们导入标准的 Flask 模块,以及 SQLALchemy 和 marshmallow 扩展,并创建我们的 Flask 应用程序:

from flask import Flask, request, jsonify
from flask_sqlalchemy import SQLAlchemy
from flask_marshmallow import Marshmallow

# Init app
app = Flask(__name__)

现在一些特定于数据库的声明,使用 Flask 应用程序初始化 SQLAlchemy:

app.config['SQLALCHEMY_DATABASE_URI'] =\
    'sqlite:///data/nobel_winners_cleaned_api_test.db'

db = SQLAlchemy(app)

现在我们可以使用 db 实例来定义我们的获胜者表,从基本声明模型继承而来。这与上一节中用来创建获胜者表的 schema 匹配:

class Winner(db.Model):
    __tablename__ = 'winners'
    id = db.Column(db.Integer, primary_key=True)
    category = db.Column(db.String)
    country = db.Column(db.String)
    date_of_birth = db.Column(db.String)
    date_of_death = db.Column(db.String)
    gender = db.Column(db.String)
    link = db.Column(db.String)
    name = db.Column(db.String)
    place_of_birth = db.Column(db.String)
    place_of_death = db.Column(db.String)
    text = db.Column(db.Text)
    year = db.Column(db.Integer)
    award_age = db.Column(db.Integer)

    def __repr__(self):
        return "<Winner(name='%s', category='%s', year='%s')>"\
            % (self.name, self.category, self.year)

使用 marshmallow 进行序列化

marshmallow 是一个非常有用的小型 Python 库,做一件事并且做得很好。引用 文档 的话说:

marshmallow 是一个用于将复杂数据类型(如对象)转换为原生 Python 数据类型的 ORM/ODM/框架无关的库。

marshmallow 使用类似于 SQLAlchemy 的 schema,可以将输入数据反序列化为应用程序级别的对象,并验证该输入数据。在这里,它的关键优势是能够从由 SQLAlchemy 提供的我们的 SQLite 数据库中获取数据,并将其转换为符合 JSON 规范的数据。

要使用 Flask-Marshmallow,我们首先创建一个 marshmallow 实例 (ma),并初始化 Flask 应用程序。然后,我们使用它创建一个 marshmallow schema,使用 SQLAlchemy 的 Winner 模型作为其基础。该 schema 还有一个 fields 属性,允许您指定要序列化的(数据库)字段:

ma = Marshmallow(app)

class WinnerSchema(ma.Schema):
    class Meta:
        model = Winner
        fields = ('category', 'country', 'date_of_birth', 'date_of_death', ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
                  'gender', 'link', 'name', 'place_of_birth', 'place_of_death',
                  'text', 'year', 'award_age')

winner_schema = WinnerSchema() ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
winners_schema = WinnerSchema(many=True)

1

要序列化的数据库字段。

2

我们声明了两个 schema 实例,一个用于返回单个记录,另一个用于多个记录。

添加我们的 RESTful API 路由

现在骨架已经搭好,让我们创建一些 Flask 路由来定义一个小型的 RESTful API。作为第一次测试,我们将创建一个路由,返回我们数据库表中的所有诺贝尔获奖者:

@app.route('/winners/')
def winner_list():
    all_winners = Winner.query.all() ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
    result = winners_schema.jsonify(all_winners) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
    return result

1

获得获胜者表中所有行的数据库查询。

2

将许多行的 marshmallow schema 获取所有获胜者的结果并将其序列化为 JSON。

我们将使用一些命令行 curl 测试 API:

$ curl http://localhost:5000/winners/
[
  {
    "award_age": 57,
    "category": "Physiology or Medicine",
    "country": "Argentina",
    "date_of_birth": "1927-10-08",
    "date_of_death": "2002-03-24",
    "gender": "male",
    "link": "http://en.wikipedia.org/wiki/C%C3%A9sar_Milstein",
    "name": "C\u00e9sar Milstein",
    "place_of_birth": "Bah\u00eda Blanca ,  Argentina",
    "place_of_death": "Cambridge , England",
    "text": "C\u00e9sar Milstein , Physiology or Medicine, 1984",
    "year": 1984
  },
  {
    "award_age": 80,
    "category": "Peace",  ...
  }...
]

现在我们有了一个 API 端点来返回所有的获胜者。那么,如何通过 ID(我们的获胜者表的主键)来检索个体呢?为此,我们从 API 调用中检索 ID,使用 Flask 的路由模式匹配,并将其用于进行特定的数据库查询。然后,我们使用我们的单行 marshmallow schema 将其序列化为 JSON:

@app.route('/winners/<id>/')
def winner_detail(id):
    winner = Winner.query.get_or_404(id) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
    result = winner_schema.jsonify(winner)
    return result

1

Flask-SQLAlchemy 提供了一个默认的 404 错误消息,如果查询无效,则可以通过 marshmallow 序列化为 JSON。

使用 curl 测试显示预期的单个 JSON 对象返回:

$ curl http://localhost:5000/winners/10/
{
  "award_age": 60,
  "category": "Chemistry",
  "country": "Belgium",
  "date_of_birth": "1917-01-25",
  "date_of_death": "2003-05-28",
  "gender": "male",
  "link": "http://en.wikipedia.org/wiki/Ilya_Prigogine",
  "name": "Ilya Prigogine",
  "place_of_birth": "Moscow ,  Russia",
  "place_of_death": "Brussels ,  Belgium",
  "text": "Ilya Prigogine ,  born in Russia , Chemistry, 1977",
  "year": 1977
}

能够在单个 API 调用中检索所有获奖者并不特别有用。让我们增加通过请求中提供的一些参数来过滤这些结果的功能。这些参数可以在 URL 查询字符串上找到,以?开头并以&分隔,跟随端点,例如http://nobel.net/api/winners?category=Physics&year=1980。Flask 提供了一个request.args对象,具有一个to_dict方法,返回 URL 参数的字典。^(3) 我们可以使用这个方法来指定我们的数据表过滤器,这可以作为键值对应用于 SQLAlchemy 的to_filter方法,这个方法可以应用于查询。这是一个简单的实现:

@app.route('/winners/')
def winner_list():
    valid_filters = ('year', 'category', 'gender', 'country', 'name') ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
    filters = request.args.to_dict()

    args = {name: value for name, value in filters.items()
            if name in valid_filters} ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
    # This for loop does the same job as the dict
    # comprehension above
    # args = {}
    # for vf in valid_filters:
    #     if vf in filters:
    #         args[vf] = filters.get(vf)
    app.logger.info(f'Filtering with the fields: {args}')
    all_winners = Winner.query.filter_by(**args) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
    result = winners_schema.jsonify(all_winners)
    return result

1

这些是我们允许进行过滤的字段。

2

我们遍历提供的过滤字段,并使用有效的字段创建我们的过滤器字典。在这里,我们使用 Python 的字典推导构建args字典。

3

使用 Python 的字典解包来指定方法参数。

让我们使用 curl 测试我们的过滤能力,使用-d(数据)参数来指定我们的查询参数:

$ curl -d category=Physics -d year=1933 --get http://localhost:5000/winners/

[
  {
    "award_age": 31,
    "category": "Physics",
    "country": "United Kingdom",
    "date_of_birth": "1902-08-08",
    "date_of_death": "1984-10-20",
    "gender": "male",
    "link": "http://en.wikipedia.org/wiki/Paul_Dirac",
    "name": "Paul Dirac",
    "place_of_birth": "Bristol , England",
    "place_of_death": "Tallahassee, Florida , US",
    "text": "Paul Dirac , Physics, 1933",
    "year": 1933
  },
  {
    "award_age": 46,
    "category": "Physics",
    "country": "Austria",
    "date_of_birth": "1887-08-12",
    "date_of_death": "1961-01-04",
    "gender": "male",
    "link": "http://en.wikipedia.org/wiki/Erwin_Schr%C3%B6dinger",
    "name": "Erwin Schr\u00f6dinger",
    "place_of_birth": "Erdberg, Vienna, Austria",
    "place_of_death": "Vienna, Austria",
    "text": "Erwin Schr\u00f6dinger , Physics, 1933",
    "year": 1933
  }
]

现在我们对获奖者数据集进行了相当细粒度的过滤,对于许多数据可视化来说,这足以提供一个大的、用户驱动的数据集,根据需要从 RESTful API 获取数据。通过 API 或 Web 表单发布或创建数据条目是那些偶尔出现并且很好掌握的要求之一。这意味着您可以将 API 用作中央数据池,并从各个位置添加到其中。在 Flask 和我们的扩展中,这也很容易实现。

向 API 提交数据

Flask 路由接受一个可选的methods参数,指定接受的 HTTP 动词。GET 动词是默认的,但通过将其设置为 POST,我们可以使用request对象上可用的数据包将数据提交到此路由中,此处为 JSON 编码数据。

我们添加了另一个/winners端点,其中包含一个methods数组,其中包含POST,然后使用 JSON 数据创建一个winner_data字典,用于在获奖者表中创建条目。然后将其添加到数据库会话中,最后提交。使用 marshmallow 进行序列化返回新条目:

@app.route('/winners/', methods=['POST'])
def add_winner():
    valid_fields = winner_schema.fields

    winner_data = {name: value for name,
                   value in request.json.items() if name in valid_fields}
    app.logger.info(f"Creating a winner with these fields: {winner_data}")
    new_winner = Winner(**winner_data)
    db.session.add(new_winner)
    db.session.commit()
    return winner_schema.jsonify(new_winner)

使用 curl 测试返回预期结果:

$ curl http://localhost:5000/winners/ \
    -X POST \
    -H "Content-Type: application/json" \
    -d '{"category":"Physics","year":2021,
         "name":"Syukuro Manabe","country":"Japan"}' ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
{
  "award_age": null,
  "category": "Physics",
  "country": "Japan",
  "date_of_birth": null,
  "date_of_death": null,
  "gender": null,
  "link": null,
  "name": "Syukuro Manabe",
  "place_of_birth": null,
  "place_of_death": null,
  "text": null,
  "year": 2021
}

1

输入数据是一个 JSON 编码的字符串。

对于数据管理可能更有用的是一个 API 端点,允许更新获奖者的数据。为此,我们可以使用 HTTP PATCH 动词调用单个 URL。与用于创建新获奖者的 POST 相同,我们会遍历 request.json 字典,并使用任何有效的字段,在本例中,所有字段都可以由 marshmallow 的序列化器使用,以更新获奖者的属性按 ID 排序:

@app.route('/winners/<id>/', methods=['PATCH'])
def update_winner(id):
    winner = Winner.query.get_or_404(id)
    valid_fields = winner_schema.fields
    winner_data = {name: value for name, value
                    in request.json.items() if name in valid_fields}
    app.logger.info(f"Updating a winner with these fields: {winner_data}")
    for k, v in winner_data.items():
        setattr(winner, k, v)
    db.session.commit()
    return winner_schema.jsonify(winner)

在这里,我们使用此 API 修补点来更新,以演示为目的,一个诺贝尔奖获得者的姓名和奖励年份:

$ curl http://localhost:5000/winners/3/ \
    -X PATCH \
    -H "Content-Type: application/json" \
    -d '{"name":"Morris Maeterlink","year":"1912"}'
{
  "award_age": 49,
  "category": "Literature",
  "country": "Belgium",
  "date_of_birth": "1862-08-29",
  "date_of_death": "1949-05-06",
  "gender": "male",
  "link": "http://en.wikipedia.org/wiki/Maurice_Maeterlinck",
  "name": "Morris Maeterlink",
  "place_of_birth": "Ghent ,  Belgium",
  "place_of_death": "Nice ,  France",
  "text": "Maurice Maeterlinck , Literature, 1911", ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
  "year": 1912
}

1

原始细节。

到目前为止,我们已经构建了一个有用的、有针对性的 API,能够根据精细的过滤条件获取数据,并更新或创建获奖者。如果您想要添加更多覆盖几个数据库表的端点,Flask 路由的样板代码和相关方法可能会有些混乱。Flask MethodViews 提供了一种方式,将我们的端点 API 调用封装在单个类实例中,使得代码更清晰、更易于扩展。将现有的 API 转移到 MethodViews 并减少认知负荷,将在 API 变得更加复杂时获得回报。

使用 MethodViews 扩展 API

我们可以重复使用我们 API 的大部分代码,并将其转移到 MethodViews 中也减少了相当数量的样板。MethodViews 将端点及其关联的 HTTP 动词(GET、POST 等)封装在单个类实例中,可以轻松扩展和适应。要将我们的获奖者表转移到一个专用资源中,我们只需将现有的 Flask 路由方法提升到一个 MethodView 类中,并进行一些小的调整。首先,我们需要导入 MethodView 类:

#...
from flask.views import MethodView
#...

SQLAlchemy 模型和 marshmallow schemas 无需更改。现在我们为获奖者集合创建一个 MethodView 实例,包含相关 HTTP 动词的方法。我们可以重用现有的路由方法。然后,我们使用 Flask 应用的 add_url_rule 方法提供一个端点,这个视图将处理它:

class WinnersListView(MethodView):

    def get(self):
        valid_filters = ('year', 'category', 'gender', 'country', 'name')
        filters = request.args.to_dict()
        args = {name: value for name, value in filters.items()
                if name in valid_filters}
        app.logger.info('Filtering with the %s fields' % (str(args)))
        all_winners = Winner.query.filter_by(**args)
        result = winners_schema.jsonify(all_winners)
        return result

    def post(self):
        valid_fields = winner_schema.fields
        winner_data = {name: value for name,
                       value in request.json.items() if name in valid_fields}
        app.logger.info("Creating a winner with these fields: %s" %
                        str(winner_data))
        new_winner = Winner(**winner_data)
        db.session.add(new_winner)
        db.session.commit()
        return winner_schema.jsonify(new_winner)

app.add_url_rule("/winners/",
                 view_func=WinnersListView.as_view("winners_list_view"))

为每个表条目创建 HTTP 方法遵循相同的模式。我们会添加一个删除方法以确保万无一失。成功的 HTTP 删除应该返回 204(无内容)HTTP 代码和一个空的内容包:

class WinnerView(MethodView):

    def get(self, winner_id):
        winner = Winner.query.get_or_404(winner_id)
        result = winner_schema.jsonify(winner)
        return result

    def patch(self, winner_id):
        winner = Winner.query.get_or_404(winner_id)
        valid_fields = winner_schema.fields
        winner_data = {name: value for name,
                       value in request.json.items() if name in valid_fields}
        app.logger.info("Updating a winner with these fields: %s" %
                        str(winner_data))
        for k, v in winner_data.items():
            setattr(winner, k, v)
        db.session.commit()
        return winner_schema.jsonify(winner)

    def delete(self, winner_id):
        winner = Winner.query.get_or_404(winner_id)
        db.session.delete(winner)
        db.session.commit()
        return '', 204

app.add_url_rule("/winners/<winner_id>",
                 view_func=WinnerView.as_view("winner_view")) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)

1

所有命名的、模式匹配的参数都会传递给所有 MethodViews 方法。

让我们使用 curl 删除其中一位获奖者,指定详细输出:

$ curl http://localhost:5000/winners/858 -X DELETE -v
*   Trying 127.0.0.1...
* Connected to localhost (127.0.0.1) port 5000 (#0)
> DELETE /winners/858 HTTP/1.1
> Host: localhost:5000
> User-Agent: curl/7.47.0
> Accept: */*
>
* HTTP 1.0, assume close after body
< HTTP/1.0 204 NO CONTENT
< Content-Type: application/json
< Server: Werkzeug/2.0.2 Python/3.8.9
< Date: Sun, 27 Mar 2022 15:35:51 GMT
<
* Closing connection 0

通过在专用端点上使用 MethodViews,我们可以减少大量 Flask 路由的样板代码,并使代码库更易于使用和扩展。举个例子,让我们看看如何添加一个非常方便的 API 功能,即分页或分块数据的能力。

分页数据返回

如果你有大型数据集并预计有大量结果集,接收分页数据的能力是一个非常有用的 API 特性;对于许多用例来说,这是一个至关重要的特性。

SQLAlchemy 有一个方便的paginate方法,可以在查询上调用以返回指定页面大小的数据页面。 要将分页添加到我们的获奖者 API 中,我们只需添加几个查询参数来指定页面和页面大小。 我们将使用 _page_page-size,并在前面加上下划线以区分它们与我们可能应用的任何过滤器查询。

这是调整过的get方法:

class WinnersListView(MethodView):

    def get(self):
        valid_filters = ('year', 'category', 'gender', 'country', 'name')
        filters = request.args.to_dict()
        args = {name: value for name, value in filters.items()
                if name in valid_filters}

        app.logger.info(f'Filtering with the {args} fields')

        page = request.args.get("_page", 1, type=int) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
        per_page = request.args.get("_per-page", 20, type=int)

        winners = Winner.query.filter_by(**args).paginate(page, per_page) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
        winners_dumped = winners_schema.dump(winners.items)

        results = {
            "results": winners_dumped,
            "filters": args,
            "pagination": ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
            {
                "count": winners.total,
                "page": page,
                "per_page": per_page,
                "pages": winners.pages,
            },
        }

        make_pagination_links('winners', results) ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/4.png)

        return jsonify(results)
    # ...

1

具有合理默认值的分页参数。

2

在这里,我们使用 SQLAlchemy 的paginate方法以及我们的pageper_page分页变量。

3

我们返回我们的分页结果和其他有意义的内容。 在pagination字典中,我们提供有用的反馈 - 返回的页面和总数据集的大小。

4

我们将使用此函数添加一些方便的 URL 以获取上一页或下一页。

按照惯例,返回上一页和下一页的 URL 端点非常有用,以便轻松访问整个数据集。 我们有一个小小的make_pagination_links函数来实现这一点,它将这些便捷的 URL 添加到分页字典中。 我们将使用 Python 的 urllib 库构建我们的 URL 查询字符串:

#...
import urllib.parse
#...
def make_pagination_links(url, results):
    pag = results['pagination']
    query_string = urllib.parse.urlencode(results['filters']) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)

    page = pag['page']
    if page > 1:
        prev_page = url + '?_page=%d&_per-page=%d%s' % (page-1,
                                                        pag['per_page'],
                                                        query_string)
    else:
        prev_page = ''

    if page < pag['pages']: ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
        next_page = url + '?_page=%d&_per-page=%d%s' % (page+1,
                                                        pag['per_page'],
                                                        query_string)
    else:
        next_page = ''

    pag['prev_page'] = prev_page
    pag['next_page'] = next_page

1

我们将从过滤查询重新生成我们的查询字符串,例如,&category=Chemistry&year=1976urllib 的解析模块将过滤器字典转换为正确格式的 URL 查询。

2

在适用的情况下添加上一页和下一页的 URL,将任何过滤查询附加到结果中。

让我们使用 curl 来测试我们的分页数据。 我们将添加一个筛选器以获取所有诺贝尔物理学奖获得者:

$ curl -d category=Physics  --get http://localhost:5000/winners/

{
  "filters": {
    "category": "Physics"
  },
  "pagination": {
    "count": 201,
    "next_page": "?_page=2&_per-page=20&category=Physics", ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
    "page": 1,
    "pages": 11,
    "per_page": 20,
    "prev_page": ""
  },
  "results":  ![2
    {
      "award_age": 81,
      "category": "Physics",
      "country": "Belgium",
      "date_of_birth": "1932-11-06",
      "date_of_death": "NaT",
      "gender": "male",
      "link": "http://en.wikipedia.org/wiki/Fran%C3%A7ois_Englert",
      "name": "Fran\u00e7ois Englert",
      "place_of_birth": "Etterbeek ,  Brussels ,  Belgium",
      "place_of_death": null,
      "text": "Fran\u00e7ois Englert , Physics, 2013",
      "year": 2013
    },
    {
      "award_age": 37,
      "category": "Physics",
      "country": "Denmark",
      "date_of_birth": "1885-10-07",
      "date_of_death": "1962-11-18",
      "gender": "male",
      "link": "http://en.wikipedia.org/wiki/Niels_Bohr",
      "name": "Niels Bohr",
    ...
    }]}

1

这是第一页,因此没有可用的上一页,但提供了下一页的 URL,以便轻松消费数据。

2

在获奖者表中包含前 20 位物理学家的结果数组。

借助像 marshmallow 这样强大的库作为 Flask 扩展集成,很容易制作自己的 API,而无需求助于专门的 RESTful Flask 库,根据经验表明,这可能不会长期存在。

使用 Heroku 远程部署 API

拥有像我们刚刚构建的本地开发数据服务器一样的服务器非常适合用于原型设计、测试数据流以及处理数据集过大以至于无法舒适地作为 JSON(或等效)文件消耗的各种数据可视化任务。但这意味着任何尝试可视化的人都需要运行一个本地数据服务器,这只是需要考虑的又一件事情。这就是将数据服务器作为远程资源放在网络上的非常有用之处。有各种方法可以做到这一点,但可能是 Pythonista 们最喜欢的方式之一(包括我自己),是使用Heroku,这是一个使得部署 Flask 服务器非常简单的云服务。让我们通过将我们的诺贝尔数据服务器放在网络上来演示这一点。

首先,您需要创建一个免费的 Heroku 账号。然后,您需要为您的操作系统安装Heroku 客户端工具

安装了这些工具后,您可以通过从命令行运行login来登录 Heroku:

$ heroku login
heroku: Press any key to open up the browser to login or q to exit
 ›   Warning: If browser does not open, visit
 ›   https://cli-auth.heroku.com/auth/browser/***
heroku: Waiting for login...
Logging in... done
Logged in as me@example.com

现在您已登录,我们将创建一个 Heroku 应用程序并将其部署到网络上。首先,我们创建一个应用目录(heroku_api),并将我们的 Flask API api_rest.py文件放入其中。我们还需要一个 Procfile 文件,一个requirements.txt文件,以及nobel_winners_cleaned_api.db SQLite 数据库来提供服务:

heroku_api
├── api_rest.py
├── data
│   ├── nobel_winners_cleaned_api.db
├── Procfile
└── requirements.txt

Procfile 用于告诉 Heroku 如何以及如何部署。在这种情况下,我们将使用 Python 的Gunicorn WSGI HTTP 服务器来处理与我们的 Flask 应用程序的 Web 流量,并将其作为 Heroku 应用程序运行。Procfile 如下所示:

web: gunicorn api_rest:app

除了 Procfile 外,Heroku 还需要知道要为应用程序安装的 Python 库。这些库在requirements.txt文件中找到:

Flask==2.0.2
gunicorn==20.1.0
Flask-Cors==3.0.10
flask-marshmallow==0.14.0
Flask-SQLAlchemy==2.5.1
Jinja2==3.0.1
marshmallow==3.15.0
marshmallow-sqlalchemy==0.28.0
SQLAlchemy==1.4.26
Werkzeug==2.0.2

配置文件已就位后,我们可以通过从命令行运行create来创建一个 Heroku 应用程序。

现在,我们将使用git来初始化 Git 目录并添加现有文件:

$ git init
$ git add .
$ git commit -m "First commit"

使用初始化的git,我们只需创建我们的 Heroku 应用程序:^(4)

$ heroku create flask-rest-pyjs2

现在,部署到 Heroku 只需一次git push

$ git push heroku master

每次您对本地代码库进行更改时,只需将它们推送到 Heroku,网站就会更新。

让我们使用 curl 测试 API,获取物理学获奖者的第一页:

 $ curl -d category=Physics --get
                       https://flask-rest-pyjs2.herokuapp.com/winners/
{"filters":{"category":"Physics"},"pagination":{"count":201,
"next_page":"winners/?_page=2&_per-page=20&category=Physics","page":1,
"pages":11,"per_page":20,"prev_page":""},"results":[{"award_age":81,
"category":"Physics","country":"Belgium","date_of_birth":"1932-11-06",
"date_of_death":"NaT","gender":"male","link":"http://en.wikipedia.org/wiki/
Fran%C3%A7ois_Englert","name":"Fran\u00e7ois Englert", ... }

CORS

为了从 Web 浏览器中使用 API,我们需要处理跨源资源共享(CORS)对服务器数据请求的限制。我们将使用 Flask CORS 扩展,并以默认方式运行,允许来自任何域的请求访问数据服务器。这只需要向我们的 Flask 应用程序添加几行代码:

# ...
from flask_cors import CORS
# Init app
app = Flask(__name__)
CORS(app)

Flask-CORS 库可用于指定允许访问哪些资源的域。在这里,我们允许一般访问。

使用 JavaScript 消耗 API

要从网页应用/页面使用数据服务器,我们只需使用fetch请求数据。这个示例通过继续获取页面来消耗所有分页数据,直到next_page属性为空字符串为止:

let data
async function init() {
  data = await getData('winners/?category=Physics&country=United States') ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
  console.log(`${data.length} US Physics winners:`, data)
  // Send the data to a suitable charting function
  drawChart(data)
}

init()

async function getData(ep='winners/?category=Physics'){ ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
  let API_URL = 'https://flask-rest-pyjs2.herokuapp.com/'
  let data = []
  while(true) {
    let response = await fetch(API_URL + ep) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
    .then(res => res.json()) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
    .then(data => {
      return data
    })

    ep = response.pagination.next_page
    data = data.concat(response.results) // add the page results
    if(!ep) break // no next-page so break out of the loop
  }
  return data
}

1

从服务器传来的数据是异步的,因此我们使用异步函数来消费它。

2

await 等待异步 Promise 自行解决,提供其值。

3

我们将响应数据转换为 JSON,将其传递给下一个 then 调用,该调用返回服务器数据。

JS 调用将期望的结果输出到控制台:

89 US Physics winners:
[{
    award_age: 42
    category: "Physics"
    country: "United States"
    date_of_birth: "1969-12-16"
    date_of_death: "NaT"
    gender: "male"
    link: "http://en.wikipedia.org/wiki/Adam_G._Riess"
    name: "Adam G. Riess"
    place_of_birth: "Washington, D.C., United States"
    place_of_death: null
    text: "Adam G. Riess , Physics, 2011"
    year: 2011
  }, ...
}]

我们现在拥有一个基于 Web 的 RESTful 数据 API,可以从任何地方访问(受到我们的 CORS 限制)。正如你所见,低开销和易用性使得 Heroku 难以匹敌。它已经运行了一段时间,已经成为一个非常精细的设置。

摘要

我希望这一章已经展示了,通过几个强大的扩展,很容易就能自己搭建 RESTful API。要使其达到工业标准,需要更多的工作和一些测试,但对于大多数数据可视化任务来说,该 API 将提供处理大型数据集并允许用户自由探索的能力。至少它展示了快速测试用户精细调整数据集的可视化方法有多么容易。仪表板是这种远程获取数据的预期应用之一。

通过 Heroku 轻松部署 API 的能力意味着大型数据集可以在不运行本地数据服务器的情况下切割和切块——非常适合向客户或同事演示雄心勃勃的数据可视化。

^(1) 这些创建、读取、更新和删除方法构成了CRUD 首字母缩写

^(2) 本质上,RESTful 意味着资源由无状态、可缓存的 URI/URL 标识,并由 GET 或 POST 等 HTTP 动词进行操作。参见维基百科的解释和这个Stack Overflow 的讨论

^(3) 从技术上讲,URL 查询字符串形成了一个多字典,允许同一个键有多个出现。对于我们的 API,我们期望每个键只有一个实例,因此转换为字典是可以的。

^(4) 你可以从 Heroku 仪表板执行此操作,然后使用 git remote - <app_name> 将当前 Git 目录附加到应用上。

第五部分:使用 D3 和 Plotly 可视化您的数据

在本书的这一部分,我们利用在第六章从网络上抓取并在第九章清理的辛苦获取的诺贝尔奖数据集,利用基于 Python 和 JS 的 Plotly 库和 D3 这个重量级的 JS 数据可视化库,将其转化为现代、引人入胜、交互式的 Web 可视化。

我们将详细介绍 D3 诺贝尔奖数据可视化的实现过程,随着学习过程中获取 D3 和 JavaScript 知识。首先,让我们利用在第十一章获得的见解,想象一下我们的可视化应该是什么样子。

您可以在本书的 GitHub 仓库的nobel_viz目录中找到这个可视化的 Python 和 JavaScript 源代码(有关详细信息,请参见“附带的代码”)。

dpj2 p501

图 V-1. 我们的数据可视化工具链:获取数据
提示

您可以在书籍的 GitHub 仓库找到本书这部分的代码。

第十四章:使用 Matplotlib 和 Plotly 将图表带到网络上

在本章中,我们将看到如何将您的 pandas 数据清理和探索成果带到网络上。通常,一个良好的静态可视化图是展示数据的好方法,我们将从展示如何使用 Matplotlib 来实现这一点开始。有时,用户交互真的可以丰富数据可视化——我们将看到如何使用 Python 的 Plotly 库来创建交互式可视化图表,并将这些,包括用户交互 (UI),全部转移到网页上。

我们还将看到学习 Plotly 的 Python 库如何使您具备使用本地 JavaScript 库的能力,这实际上可以扩展您的网络数据可视化的可能性。我们将通过创建一些简单的 JS UI 来更新我们的本地 Plotly 图表来演示这一点。

使用 Matplotlib 创建静态图表

通常,最适合工作的图表是静态图表,其中完全由创建者控制编辑。Matplotlib 的一个优点是它能够生成从高清网络 PNG 到 SVG 渲染的全面范围格式的印刷质量图表,具有与文档大小完美匹配的矢量基元。

对于 Web 图形,无处不在且推荐的格式是便携式网络图形 (PNG) 格式,正如其名称所示,它是为此工作而设计的。让我们从我们的诺贝尔探索中选择几个图表 (请参阅 第十一章),并将它们以 PNG 的形式提供到网络上,在此过程中做一个小展示。

在 “国家趋势” 中,我们看到通过绝对数字衡量国家的奖项产出得到了一个非常不同的图片,考虑到人口规模的情况下,这是一个人均测量。我们制作了一些显示此内容的条形图。现在让我们将这个探索性发现转化为一个演示文稿。我们将使用垂直条形图来使国家名称更易

我们获取由 pandas 绘图方法返回的 Matplotlib 坐标轴,并对其进行一些调整,将其背景色更改为浅灰色(#eee),并添加一两个标签:

ax = df_countries[df_countries.nobel_wins > 2]\ ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
    .sort_values(by='nobel_wins_per_capita', ascending=True)\
    .nobel_wins_per_capita.plot(kind='barh',\ ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
        figsize=(5, 10), title="Relative prize numbers")
ax.set_xlabel("Nobel prizes per capita")
ax.set_facecolor("#eee")
plt.tight_layout() ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
plt.savefig("country_relative_prize_numbers.png")

1

具有至少三个奖项的国家的阈值。

2

我们想要一个水平条形图,种类为 barh

3

使用 tight_layout 方法可减少保存图形时丢失图表元素的可能性。

我们对绝对数字执行相同的操作,生成两个水平条形图 PNG。为了在网络上展示这些,我们将使用一些在 第四章 中学到的 HTML 和 CSS:

<!-- index.xhtml -->
<div class="main">
  <h1 class='title'>The Nobel Prize</h1>
  <h2>A few exploratory nuggets</h2>

  <div class="intro">
    <p>Some nuggets of data mined from a dataset of Nobel prize
     winners (2016).</p>
  </div>

  <div class="container" id="by-country-container">

    <div class="info-box">
      <p>These two charts compare Nobel prize winners by
         country. [...] that Sweden, winner by a relative
          metric, hosts the prize.</p>
    </div>

    <div class="chart-wrapper" id="by-country">

      <div class="chart">
        <img src="images/country_absolute_prize_numbers.png" ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
         alt="">
      </div>

      <div class="chart">
        <img src="images/country_relative_prize_numbers.png"
         alt="">
      </div>

    </div>
  </div>
</div>

1

图像位于相对于 index.xhtml 的子目录中

在标题、副标题和介绍之后,我们有一个主容器,在其中有一个包含两个图表和一个信息框的图表包装器div

我们将使用一些 CSS 来调整内容的大小、位置和样式。关键的 CSS 是使用 flex-box 将图表和信息框分布在一行中,并通过为图表包装器赋予两个权重和信息框赋予一个权重来使它们具有相等的宽度:

html,
body {
  height: 100%;
  font-family: Georgia, serif;
  background: #fff1e5;
  font-size: 1.2em;
}

h1.title {
  font-size: 2.1em;
}

.main {
  padding: 10px;
  padding-bottom: 100px;
  min-width: 800px;
  max-width: 1200px;
}

.container {
  display: flex;
}

.chart-wrapper {
  display: flex; ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
  flex: 2; ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
}

.chart {
  flex: 1;
  padding: 0 1.5em;
}

.chart img {
  max-height: 600px;
}

.info-box {
  font-family: sans-serif;
  flex: 1; ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
  font-size: 0.7em;
  padding: 0 1.5em;
  background: #ffebd9;
}

1

此容器的图表和信息框是通过 flex 控制的。

2

图表包装器的宽度是信息框的两倍。

图 14-1(左)显示了生成的网页。

dpj2 1401

图 14-1. 两幅静态图表

适应屏幕大小

现代 Web 开发及相关数据可视化面临的一个挑战是适应现在使用的众多设备。智能手机和平板电脑可以平移和捏/缩放,这意味着同样的可视化可以在所有设备上使用。使可视化具有适应性并不容易,并且很快就会遭遇组合爆炸问题。通常,妥协的组合是最佳方式。

但是在某些情况下,可以通过使用 CSS 的media属性来根据设备屏幕大小调整样式,通常使用变化的屏幕宽度来触发专用样式的使用,这是一种简单的胜利。我们将使用刚刚创建的 Nobel 网页来演示这一点。

默认的图表布局在大多数笔记本电脑或个人电脑屏幕上都很好,但设备宽度减小时,图表和信息框会变得有些混乱,信息框会变长以容纳文本内容。通过在设备宽度达到 1,000 像素时触发 flex-box 的变化,我们可以使可视化在小屏设备上更易于消化。

在此,我们添加了一个媒体屏幕触发器,将不超过 1,000 像素的设备应用不同的flex-direction值。与其在一行中显示信息框和图表,我们将它们显示在一列中,并反转顺序将信息框放在底部。结果显示在图 14-1(右)中:

/* When the browser is 1000 pixels wide or less */
@media screen and (max-width: 1000px) {
  #by-country-container {
    flex-direction: column-reverse;
  }
}

使用远程图片或资源

您可以使用远程资源,例如 Dropbox 或 Google 托管的图像,通过获取它们的共享链接并将其用作图像源。例如,以下img标记使用 Dropbox 图像而不是本地托管的图像作为图 14-1 的示例:

      <div class="chart">
        <img src="https://www.dropbox.com/s/422ugyhvfc0zg99/
 country_absolute_prize_numbers.png?raw=1" alt="">
      </div>

      <div class="chart">
        <img src="https://www.dropbox.com/s/n6rfr9kvuvir7gi/
 country_relative_prize_numbers.png?raw=1" alt="">
      </div>

使用 Plotly 进行图表化

对于以 PNG 或 SVG 格式呈现的静态图表,Matplotlib 具有极高的可定制性,尽管其 API 可能不够直观。但是,如果你希望你的图表具有任何动态/交互元素,例如使用按钮或选择器更改或过滤数据集的能力,则需要使用不同的图表库,这就是 Plotly^(1) 的用武之地。

Plotly 是一个基于 Python(以及其他语言)的图表库,类似于 Matplotlib,可以在交互式 Jupyter 笔记本会话中使用。它提供了各种图表形式,其中一些在 Matplotlib 中找不到,并且配置起来比 Matplotlib 更容易。因此,单单因为这个原因它就是一个有用的工具,但 Plotly 的亮点在于其能够将这些图表以及任何脚本化的交互式小部件导出到 Web 上。

如前所述,用户交互和动态图表通常是多余的,但即使在这种情况下,Plotly 也有一些不错的增值功能,比如鼠标悬停时提供特定的柱状组信息。

基本图表

让我们看看 Plotly 是如何操作的,通过复制 Matploblib 中的一个图表来展示“奖励分布的历史趋势”。首先,我们将从诺贝尔奖数据集创建一个 DataFrame,显示三个地理区域的累积奖项:

new_index = pd.Index(np.arange(1901, 2015), name='year')

by_year_nat_sz = df.groupby(['year', 'country'])\
    .size().unstack().reindex(new_index).fillna(0)

# Our continental country list created by selecting the biggest
# two or three winners in the three continents compared.
regions = [
{'label':'N. America',
'countries':['United States', 'Canada']},
{'label':'Europe',
'countries':['United Kingdom', 'Germany', 'France']},
{'label':'Asia',
'countries':['Japan', 'Russia', 'India']}
]
# Creates a new column with a region label for each dict in the
# regions list, summing its countries members.
for region in regions:
    by_year_nat_sz[region['label']] =\
    by_year_nat_sz[region['countries']].sum(axis=1)
# Creates a new DataFrame using the cumulative sum of the
# new region columns.
df_regions = by_year_nat_sz[[r['label'] for r in regions]].\
    cumsum()

这使我们得到了一个名为 df_regions 的 DataFrame,其中包含列累积求和:

df_regions
country  N. America  Europe  Asia
year
1901            0.0     4.0   0.0
1902            0.0     7.0   0.0
1903            0.0    10.0   0.0
1904            0.0    13.0   1.0
1905            0.0    15.0   1.0
...             ...     ...   ...
2010          327.0   230.0  36.0
2011          333.0   231.0  36.0
...

Plotly Express

Plotly 提供了一个 express 模块,可以快速绘制图表草图,非常适合在笔记本中进行探索性迭代。该模块为线图、条形图等提供了高级对象,并且可以接受 pandas DataFrame 作为参数来解释列数据。^(2) 刚刚创建的区域 DataFrame 可以直接被 Plotly Express 使用,用几行代码构建一条线图。这将生成图表 图 14-2(左侧):

# load the express module
import plotly.express as px
# use the line method with a suitable DataFrame
fig = px.line(df_regions)
fig.show()

dpj2 1402

图 14-2. 使用 Plotly 的累积奖项

注意默认情况下用于 x 轴的行索引标签,以及鼠标悬停时显示的工具提示,显示线段部分的信息。

另一个需要注意的是图例标签取自分组索引,这种情况下为 country。我们可以轻松地将其改为更合理的名称,例如 Regions

fig = px.line(df_regions, labels={'country': 'Regions'})
      line_dash='country', line_dash_sequence=['solid', 'dash', 'dot']) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
)
fig.show()

1

默认情况下,Plotly 为线条着色,但为了在本书的印刷版本中区分它们,我们可以调整它们的样式。为此,我们将 line_dash 参数设置为国家组,并将 line_dash_sequence 设置为我们想要的线条样式。^(3)

Plotly Express 使用简单,并引入了一些新颖的图表^(4)。对于快速数据草图,它与 pandas 的 Matplotlib 包竞争,后者直接在 DataFrame 上运行。但如果您想对您的图表有更多控制,并真正利用 Plotly 的优势,我建议专注于使用 Plotly 图表和图形对象。这个 API 更复杂,但显著更强大。它还有 JavaScript API 的镜像,这意味着您实际上正在学习两个库——这是一个非常有用的事情,我们将在本章后面看到。

Plotly 图形对象

使用 Plotly 图形对象需要一些样板代码,但无论是创建条形图、小提琴图、地图等,模式基本相同。思路是使用图形对象数组,如散点(在线模式下的线)、条形、蜡烛、方框等,作为图表的数据。layout 对象用于提供其他图表特性。

以下代码生成 图 14-2(右侧)的图表。请注意鼠标悬停时的自定义工具提示:

import plotly.graph_objs as go

traces = [] ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
for region in regions:
    name = region['label']
    traces.append(
        go.Scatter(
            x=df_regions.index, # years
            y=df_regions[name], # cum. prizes
            name=name,
            mode="lines", ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
            hovertemplate=f"{name}<br>%{{x}}<br>$%{{y}}<extra></extra>" ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
            line=dict(dash=['solid', 'dash', 'dot'][len(traces)]) ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/4.png)
        )
    )
layout = go.Layout(height=600, width=600,\ ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/5.png)
    xaxis_title="year", yaxis_title="cumulative prizes")
fig = go.Figure(traces, layout) ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/6.png)
fig.show()

1

我们将创建一个线图形对象数组,用作我们图表的数据。

2

在线模式散点对象的点是连接的。

3

您可以提供一个 HTML 字符串模板,将出现在鼠标悬停时。在那一点上提供 xy 变量。

4

Scatter 对象有一个 line 属性,允许您设置各种线属性,如颜色、线样式、线形状等^(5) 为了在黑白印刷书中区分我们的线条,我们希望设置它们的样式。为此,我们使用 traces 数组的大小(len)作为样式数组的索引,按顺序设置线条样式。

5

除了数据外,我们还提供一个 layout 对象,定义了诸如图表尺寸、x 轴标题等内容。

6

使用我们的图形对象数组和布局创建图表。

使用 Plotly 进行地图绘制

Plotly 的另一个重要优势是其地图库,特别是其集成 Mapbox 生态系统 的能力,Mapbox 是最强大的网络切片地图资源之一。 Mapbox 的切片系统快速高效,并且开启了雄心勃勃的地图可视化可能性。

让我们利用我们的诺贝尔奖数据集展示一些 Plotly 地图绘制,目标是可视化奖项的全球分布。

首先,我们将使用 DataFrame 制作各获奖国家按类别的奖项统计,并通过汇总类别数字添加一个 Total 列:

df_country_category = df.groupby(['country', 'category'])\
    .size().unstack()
df_country_category['Total'] = df_country_category.sum(1)
df_country_category.head(3) # top three rows
#category   Chemistry  Economics  Literature  Peace  Physics  \
#country
#Argentina        1.0        NaN         NaN    2.0      NaN
#Australia        NaN        1.0         1.0    NaN      1.0
#Austria          3.0        1.0         1.0    2.0      4.0
#
#category   Physiology or Medicine  Total
#country
#Argentina                     2.0    5.0
#Australia                     6.0    9.0
#Austria                       4.0   15.0

我们将使用 Total 列来筛选行,将国家限制为至少获得三次诺贝尔奖的国家。我们将复制此切片以避免任何 pandas DataFrame 错误,如果试图更改视图的话:

df_country_category = df_country_category.\
    loc[df_country_category.Total > 2].copy()
df_country_category

手头有按国家统计的奖项数量,我们需要一些地理数据,即各国的中心经纬度坐标。这是一个演示 Geopy 的机会,这是一个很酷的小型 Python 库,可以执行这种工作,以及其他许多地理工作。

首先,使用 pip 或类似工具进行安装:

!pip install geopy

现在我们可以使用 Nominatim 模块基于国家名称字符串提供位置信息。我们通过提供用户代理字符串创建一个地理定位器:

from geopy.geocoders import Nominatim

geolocator = Nominatim(user_agent="nobel_prize_app")

使用地理定位器,我们可以遍历数据框索引中的几个国家,以展示可用的地理数据:

for name in df_country_category.index[:5]:
    location = geolocator.geocode(name)
    print("Name: ", name)
    print("Coords: ", (location.latitude, location.longitude))
    print("Raw details: ", location.raw)
#Name:  Argentina
#Coords:  (-34.9964963, -64.9672817)
#Raw details:  {'place_id': 284427148, 'licence': 'Data ©
#OpenStreetMap contributors, ODbL 1.0\. https://osm.org/
#copyright', 'osm_type': 'relation', 'osm_id': 286393,
#'boundingbox': ['-55.1850761', '-21.7808568', '-73.5605371',
#[...] }

让我们向 DataFrame 添加地理纬度 (Lat) 和经度 (Lon) 列,使用我们的地理定位器:

lats = {}
lons = {}
for name in df_country_category.index:
    location = geolocator.geocode(name)
    if location:
        lats[name] = location.latitude
        lons[name] = location.longitude
    else:
        print("No coords for %s"%name)

df_country_category.loc[:,'Lat'] = pd.Series(lats)
df_country_category.loc[:,'Lon'] = pd.Series(lons)
df_country_category
#category   Chemistry  Economics  Literature  Peace  Physics  \
#country
#Argentina        1.0        NaN         NaN    2.0      NaN
#Australia        NaN        1.0         1.0    NaN      1.0
#
#category   Physiology or Medicine  Total        Lat         Lon
#country
#Argentina                     2.0    5.0 -34.996496  -64.967282
#Australia                     6.0    9.0 -24.776109  134.755000

我们将使用一些地图标记来反映各国奖项数量。我们希望圆圈的大小反映奖项总数,因此我们需要一个小函数来获取适当的半径。我们将使用 scale 参数来允许手动调整标记大小:

def calc_marker_radius(size, scale=5):
    return np.sqrt(size/np.pi) * scale

与基本图表一样,Plotly Express 提供了一种快速制图选项,使用 pandas DataFrame 可以快速创建地图。Express 有一个专门的 scatter_mapbox 方法返回一个图表对象。这里我们使用该图表对象对地图布局进行一些更新,使用 Plotly 提供的免费地图样式之一(carto-positron)(参见 图 14-3):

import plotly.express as px
init_notebook_mode(connected=True)

size = df_country_category['Total'].apply(calc_marker_radius, args=(16,)) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
fig = px.scatter_mapbox(df_country_category, lat="Lat", lon="Lon", ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
                        hover_name=df_country_category.index, ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
                        hover_data=['Total'],
                        color_discrete_sequence=["olive"],
                        zoom=0.7, size=size)
fig.update_layout(mapbox_style="carto-positron", width=800, height=450) ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/4.png)
fig.update_layout(margin={"r":0,"t":0,"l":0,"b":0})
fig.show()

1

创建一个用于圆形标记半径的大小数组。

2

Mapbox 需要经纬度和我们计算出的 size 数组以放置标记。缩放参数表示相机在地球上的位置,默认为 0.7。

3

hover_name 提供了鼠标悬停提示的标题和我们想要的额外信息,本例中是 Total 列。

4

Plotly 提供了许多免费使用的 地图样式瓷砖集

dpj2 1403

图 14-3. 使用 Plotly Express 快速制图

与基本图表一样,Plotly 还提供了一种更强大的数据+布局映射选项,遵循熟悉的配方,创建一个图表 traces 数组和一个布局以指定诸如图例框、标题、地图缩放等内容。以下是制作我们的诺贝尔地图的方法:

mapbox_access_token = "pk.eyJ1Ij...JwFsbg" ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)

df_cc = df_country_category

site_lat = df_cc.Lat ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
site_lon = df_cc.Lon
totals = df_cc.Total
locations_name = df_cc.index

layout = go.Layout(
    title='Nobel prize totals by country',
    hovermode='closest',
    showlegend=False,
    margin ={'l':0,'t':0,'b':0,'r':0},
    mapbox=dict(
        accesstoken=mapbox_access_token,
        # we can set map details here including center, pitch and bearing..
        # try playing  with these.
#         bearing=0,
# #         center=dict(
# #             lat=38,
# #             lon=-94
# #         ),
#         pitch=0,
        zoom=0.7,
        style='light'
    ),
    width=875, height=450
)

traces = [
            go.Scattermapbox(
            lat=site_lat,
            lon=site_lon,
            mode='markers',
            marker=dict(
                size=totals.apply(calc_marker_radius, args=(7,)),
                color='olive',
                opacity=0.8
            ),
            text=[f'{locations_name[i]} won {int(x)} total prizes'\
                for i, x in enumerate(totals)],
            hoverinfo='text'
             )
]

fig = go.Figure(traces, layout=layout)
fig.show()

1

Plotly 提供了许多基于免费开放街地图的地图集,但要使用 Mapbox 特定的图层,您需要获取一个Mapbox 访问令牌。这些令牌可以免费用于个人用途。

2

我们将 DataFrame 的列和索引存储在一个更用户友好的形式中。

所生成的地图显示在图 14-4(左侧)。请注意,在鼠标悬停时生成的自定义工具提示。图 14-4(右侧)显示了一些用户交互的结果,平移和缩放以突出显示欧洲奖项分布。

dpj2 1404

图 14-4. 使用 Plotly 的图形对象进行映射

让我们扩展地图以添加一些自定义控件,使用按钮来选择奖项类别进行可视化。

使用 Plotly 添加自定义控件

Plotly 交互地图的一个很酷的功能之一是能够添加自定义控件,可以在 Python 中作为 HTML+JS 控件进行移植到 Web 上。控件 API 在我看来有点笨拙,并且仅限于一小部分控件,但是能够添加数据集选择器、滑块、过滤器等功能是一个很大的优势。在这里,我们将在我们的诺贝尔奖地图上添加一些按钮,允许用户通过类别来筛选数据集。

在继续之前,我们需要将奖项中的非数字替换为零,以避免 Plotly 标签错误的发生。你可以在前两行中看到这些情况:

df_country_category.head(2)
# Out:
#  category   Chemistry  Economics  Literature  Peace  Physics  \
# country
# Argentina        1.0        NaN         NaN    2.0      NaN
# Australia        NaN        1.0         1.0    NaN      1.0

一行 Pandas 代码就可以将这些NaN替换为零,直接进行修改:

df_country_category.fillna(0, inplace=True)

这将涉及稍微不同的 Plotly 模式,与迄今为止使用的模式略有不同。我们将首先使用layout创建我们的图形,然后通过迭代诺贝尔类别使用add_trace添加数据跟踪,同时将按钮添加到button数组中。

然后,我们将使用其update方法将这些按钮添加到布局中:

# ...
categories = ['Total', 'Chemistry',   'Economics', 'Literature',\
    'Peace', 'Physics','Physiology or Medicine',]
# ...
colors = ['#1b9e77','#d95f02','#7570b3','#e7298a','#66a61e','#e6ab02','#a6761d']
buttons = []
# ... DEFINE LAYOUT AS BEFORE
fig = go.Figure(layout=layout)
default_category = 'Total'

for i, category in enumerate(categories):
    visible = False
    if category == default_category: ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
        visible = True
    fig.add_trace(
        go.Scattermapbox(
            lat=site_lat,
            lon=site_lon,
            mode='markers',
            marker=dict(
                size=df_cc[category].apply(calc_marker_radius, args=(7,)),
                color=colors[i],
                opacity=0.8
            ),
            text=[f'{locations_name[i]} prizes for {category}: {int(x)}'\
                  for i, x in enumerate(df_cc[category])],
            hoverinfo='text',
            visible=visible
             ),
    )
    # We start with a mask array of Boolean False, one for each category (inc. Total)
    # In Python [True] * 3 == [True, True, True]
    mask = [False] * len(categories)
    # We now set the mask index corresponding to the current category to True
    # i.e. button 'Chemistry' has mask [False, True, False, False, False, False]
    mask[categories.index(category)] = True
    # Now we can use that Boolean mask to add a button to our button list
    buttons.append(
            dict(
                label=category,
                method="update",
                args=[{"visible": mask}], ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
            ),
    )

fig.layout.update( ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
    updatemenus=[
        dict(
            type="buttons",
            direction="down",
            active=0,
            x=0.0,
            xanchor='left',
            y=0.65,
            showactive=True, # show the last button clicked
            buttons=buttons
        )
    ]
)

fig.show()

1

初始情况下,类别标记集是不可见的——只有默认的Total显示。

2

我们使用掩码来设置一个可见性数组,用于该按钮,仅使相关类别的数据标记可见。

3

现在我们将这些按钮添加到我们的布局中,使用xy来垂直放置按钮组的中心,并且按钮框锚定在左侧。

点击按钮(参见图 14-5)会显示与该类别相关的数据标记,通过应用按钮的可见性掩码。虽然这感觉有点笨拙,但这是一种通过按钮按下来过滤数据的可靠方法。与本章稍后将介绍的 JavaScript+HTML 控件不同,按钮的样式化能力有限。

dpj2 1405

图 14-5. 向 Plotly 地图添加自定义控件

从笔记本到 Web 的 Plotly

现在我们在笔记本中显示了 Plotly 图表,让我们看看如何将它们转移到一个小的 Web 演示中。我们将使用 Plotly 的offline模块中的plot函数来生成所需的可嵌入 HTML+JS,因此首先导入它:

from plotly.offline import plot

使用plot,我们可以创建一个从图形生成的可嵌入字符串,可以直接提升到 Web。它包含了启动 Plotly 的 JavaScript 库和创建图表所需的 HTML 和 JavaScript 标签:

embed_string = plot(fig, output_type='div', include_plotlyjs="cdn")
embed_string
#'<div>                        <script type="text/javascript">window.PlotlyConfig
#= {MathJaxConfig: \'local\'};</script>\n        <script src="https://cdn.plot.ly/
#plotly-2.9.0.min.js"></script>                <div
#id="195b2d71-f59d-4f8a-a40a-3b8c797a918b" class="plotly-graph-div"
#style="height:600px; width:600px;"></div>            <script type="text/
#javascript"> [...]    </script>
#</div>'

如果我们整理这个字符串,我们可以看到它分解为四个部分,一个带有图表 ID 的 HTML div标签和一些 JavaScript,其中包含通过参数传递的数据和布局的newPlot调用:

<div>
  <!-- (1) JavaScript Plotly config, placed with JavaScript (.js) file -->
  <script type="text/javascript">window.PlotlyConfig = {MathJaxConfig: 'local'};
  </script>
  <!-- (2) Place bottom of HTML file to import the Plotly library
 from the cloud (content delivery network) -->
  <script src="https://cdn.plot.ly/plotly-2.9.0.min.js"></script>
  <!-- create a content div for the chart, with ID tag (to be inflated)
 (3) !! Put this div in the HTML section of the code-pen !! -->
  <div id="4dbeae4f-ed9b-4dc1-9c69-d4bb2a20eaa7" class="plotly-graph-div"
       style="height:100%; width:100%;"></div>

  <script type="text/javascript">
    // (4) Everything within this tag goes to a JavaScript (.js) file -->
    window.PLOTLYENV=window.PLOTLYENV || {};
    // Grab the 'div' tag above by ID and call Plotly's JS API on it, using the
    // embedded data and annotations
    if (document.getElementById("4dbeae4f-ed9b-4dc1-9c69-d4bb2a20eaa7"))
    {                    Plotly.newPlot("4dbeae4f-ed9b-4dc1-9c69-d4bb2a20eaa7",
                      [{"mode":"lines","name":"Korea, South",
                      "x": [0,1,2,3,4,5,6,7,8,9,10,11,12,...]}])
                };
  </script>
</div>

虽然我们可以将 HTML+JS 直接粘贴到网页中查看渲染的图表,但将 JS 和 HTML 分开是更好的实践。首先,将图表 div 放入一个小的网页中,添加一些标题、信息框等容器:

<!-- index.xhtml -->
<div class="main">
  <h1 class='title'>The Nobel Prize</h1>
  <h2>From notebook to the web with Plotly</h2>

  <div class="intro">
    <p>Some nuggets of data mined [...]</p>
  </div>

  <div class="container" id="by-country-container">

    <div class="info-box">
      <p>This chart shows the cumulative Nobel prize wins by region, taking the
       two or three highest winning countries from each.[...]</p>
    </div>

    <div class="chart-wrapper" id="by-country">
      <div id="bd54c166-3733-4b20-9bb9-694cfff4a48e" ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
      class="plotly-graph-div" style="height:100%; width:100%;"></div>
    </div>
  </div>
</div>

<script scr="scripts/plotly_charts.js"></script>
<script src="https://cdn.plot.ly/plotly-2.9.0.min.js"></script> ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)

1

Plotly 生成的div容器,其 ID 对应于 JavaScript 图表。

2

Plotly 生成的script标签,用于导入 JS 图表库。

我们将剩余的两个 JavaScript 标签的内容放入一个plotly_charts.js的 JS 文件中:

// scripts/plotly_charts.js
  window.PLOTLYENV=window.PLOTLYENV || {};
  if (document.getElementById("bd54c166-3733-4b20-9bb9-694cfff4a48e")){ ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
    Plotly.newPlot("bd54c166-3733-4b20-9bb9-694cfff4a48e", ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
      {"hovertemplate":"N. America<br>%{x}<br>$%{y}<extra></extra>", ![3
        "mode":"lines","name":"N. America",
        "x":[1901,1902,1903,1904,1905,1906,1907,1908,1909,1910,1911,1912,1913,
             1914,1915,1916,1917,1918,1919,1920,1921,1922,...]
      }])
  }

1

检查带有正确 ID 的div是否存在。

2

Plotly 的newPlot方法将在指定的容器中构建图表。

3

一个包含所有数据(xy数组)的图表对象数组,这种情况下,可以将图表从笔记本转移到 Web 页面。

页面加载时,使用 Plotly 库的newPlot方法,使用嵌入数据和布局构建图表,并在指定 ID 的div容器中通过 JS 生成 Web 页面。这产生了图 14-6 中显示的 Web 页面。

dpj2 1406

图 14-6. 从笔记本到 Web 的 Plotly

使用 Plotly 在 Python 中生成的所有图表可以通过这种方式转移到 Web。如果您计划使用多个图表,建议为每个图表准备单独的 JS 文件,因为嵌入数据可能导致文件非常长。

使用 Plotly 的原生 JavaScript 图表

能够轻松地将喜爱的图表从笔记本转移到 Web 是很棒的,但如果需要进行细微调整,则需要在笔记本和 Web 开发之间来回移动。这可能会在一段时间后变得很烦人。Plotly 的一件非常酷的事情是,你可以免费学习一个 JavaScript 图表库。Python 和 JS 的图表模式非常相似,因此很容易将 Python 中的图表代码转换为 JS,并从头开始编写 JS 图表。让我们通过将 seaborn 图表转换为 JavaScripted Plotly 来演示这一点。

在 “获奖时年龄” 中,我们使用 seaborn 生成了一些小提琴图。要将这些图转移到 Web,我们首先需要一些数据。将小数据集转换为 JSON 并仅复制生成的字符串,然后将其粘贴到 JS 文件中并将字符串解析为 JS 对象是移动小数据集的有用方法。首先,我们使用 pandas 创建仅包含 award_agegender 列的小数据集,然后生成所需的 JSON 对象数组:

df_select = df[['gender', 'award_age']]
df_select.to_json(orient='records')
#'[{"gender":"male","award_age":57},{"gender":"male","award_age":80},
#{"gender":"male","award_age":49},{"gender":"male","award_age":59},
#{"gender":"male","award_age":49},{"gender":"male","award_age":46},...}]'

我们可以将 JSON 字符串复制到 JS 文件中,并使用内置的 JSON 库将字符串解析为 JS 对象数组:

let data = JSON.parse('[{"gender":"male","award_age":57}
',{"gender":"male","award_age":80},'
'{"gender":"male","award_age":49},{"gender":"male","award_age":59},'
'{"gender":"male","award_age":48}, ... ]')

有了数据后,我们需要一些 HTML 结构来包含一个图表容器,ID 为 'award_age',用于容纳 Plotly 图表:

<div class="main">
  <h1 class='title'>The Nobel Prize</h1>
  <!-- ... -->
    <div class="chart-wrapper">
      <div class='chart' id='award_age'></div> ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
  </div>

</div>

<script src="https://cdn.plot.ly/plotly-2.9.0.min.js"></script>

1

我们将使用此容器的 ID 来告诉 Plotly 在何处构建图表。

现在我们可以构建我们的第一个 JS 原生 Plotly 图表。该模式与我们笔记本中 Python Plotly 图表的模式相匹配。首先,我们创建一个包含一些图表对象的数据数组(traces),然后创建一个布局以提供标题、标签、颜色等。然后,我们使用 newPlot 来构建图表。与 Python 的主要区别在于 newPlot 的第一个参数是容器的 ID,用于在其中构建图表:

var traces = { ![1  type: 'violin',  x: data.map(d => d.gender), ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
  y: data.map(d => d.award_age),
  points: 'none',
  box: {
    visible: true
  },
  line: {
    color: 'green',
  },
  meanline: {
    visible: true
  },
}]

var layout = {
  title: "Nobel Prize Violin Plot",
  yaxis: {
    zeroline: false
  }
}

Plotly.newPlot('award_age', traces, layout); ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)

1

根据通常的 Plotly 模式,我们首先创建一个图表对象数组。

2

我们使用 JS 数组的 map 方法和箭头函数的简写来生成获奖者性别和年龄的数组。

3

Plotly 将把图表渲染到我们的 div 容器中,其 ID 为 'award_age'

所生成的小提琴图显示在 图 14-7 中。

dpj2 1407

图 14-7. 使用 Plotly JS 的小提琴图

如您所见,Plotly 的 JS API 与 Python 的 API 相匹配,而且如果有什么不同的话,JS 更为简洁。将数据传递与图表构建分开使代码库易于处理,并且意味着调整和精炼不需要返回 Python API。对于小数据快速查看,解析 JSON 字符串可以快速完成工作。

但是,如果你想要有更大的数据集,并真正利用 JS 网络上下文的能力,标准的数据传递方式是通过 JSON 文件。对于几兆字节的数据集,这为数据可视化提供了最大的灵活性。

获取 JSON 文件

另一种将数据传递到网页的方式是将 DataFrame 导出为 JSON,然后使用 JavaScript 获取它,进行任何必要的进一步处理,然后将其传递给本地 JS 图表库(或者 D3)。这是一种非常灵活的工作流程,为 JS 数据可视化提供了最大的自由度。

权力分离,允许 Python 专注于其数据处理能力,而 JavaScript 专注于其优越的数据可视化能力,提供了数据可视化的甜蜜点,是产生宏大网络数据可视化的最常见方式。

首先,我们将我们的诺贝尔获奖者 DataFrame 保存为 JSON,使用专用方法。通常,我们希望数据以对象数组的形式出现,这需要一个orient参数为'records'

df.to_json('nobel_winners.json', orient='records')

拥有 JSON 数据集后,让我们使用它来使用 JavaScript API 生成一个 Plotly 图表,就像前一节一样。我们需要一些 HTML,包括一个带 ID 的容器来构建图表,并且一个脚本链接来导入我们的 JS 代码:

<!-- index.xhtml -->
<link rel="stylesheet" href="styles/index.css">

<div class="main">
  <h1 class='title'>The Nobel Prize</h1>
  <!-- ... -->
    <div class="chart-wrapper">
      <div class='chart' id='gender-category'> </div> ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
    </div>
  </div>
</div>

<script src="https://cdn.plot.ly/plotly-2.9.0.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.4.2/d3.min.js"></script>
<script src="scripts/index.js"></script> ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)

1

我们将使用这个 ID 告诉 Plotly 在哪里构建图表。

2

从 scripts 文件夹导入索引 JS 文件。

在我们的 JS 入口点,我们使用 D3 的json实用方法来导入诺贝尔获奖者数据集并将其转换为 JS 对象数组。然后我们将数据传递给makeChart函数,在那里 Plotly 将展示其魔力:

// scripts/index.js
d3.json('data/nobel_winners.json').then(data => {
  console.log("Dataset: ", data)
  makeChart(data)
})

控制台显示我们的获奖者数组:

[
  {category: 'Physiology or Medicine', country: 'Argentina',
   date_of_birth: -1332806400000, date_of_death: 1016928000000,
   gender: 'male', …},
  {category: 'Peace', country: 'Belgium',
   date_of_birth: -4431715200000, date_of_death: -1806278400000,
   gender: 'male', …}
   [...]
]

makeChart函数中,我们使用 D3 非常方便的rollup 方法来按性别和类别分组我们的诺贝尔数据集,然后通过返回的成员数组的长度来提供组大小:

function makeChart(data) {
  let cat_groups = d3.rollup(data, v => v.length, ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
                      d=>d.gender, d=>d.category)
  let male = cat_groups.get('male')
  let female = cat_groups.get('female')
  let categories = [...male.keys()].sort() ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)

  let traceM = {
    y: categories,
    x: categories.map(c => male.get(c)), ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
    name: "male prize total",
    type: 'bar',
    orientation: 'h'
  }
    let traceF= {
    y: categories,
    x: categories.map(c => female.get(c)),
    name: "female prize total",
    type: 'bar',
    orientation: 'h'
  }

  let traces = [traceM, traceF]
  let layout = {barmode: 'group', margin: {l:160}} ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/4.png)

  Plotly.newPlot('gender-category', traces, layout)
}

1

从获奖者对象数组中,rollup按性别分组,然后按类别分组,然后将结果组的大小/长度作为 JS Map 返回:{male: {Physics: 199, Economics: 74, ...}, female: {...}}

2

我们使用 JS 的... 扩展运算符 来从类别键产生一个数组,然后进行排序以生成我们水平条形图的 y 值。

3

我们将排序后的类别映射到它们的组值,以提供条形图的高度。

4

我们增加水平条形图的左边距,以容纳长标签。

所生成的条形图显示在图 14-8 中。由于有完整的诺贝尔获奖者数据集可用,可以轻松生成一系列图表,无需从 Python 切换到 JS。Plotly API 相当直观和易于发现,使其成为数据可视化工具箱的重要补充。让我们看看如何通过一些 HTML+JS 自定义控件来扩展它。

dpj2 1408

图 14-8. 使用 Plotly JS 绘制的条形图

使用 JavaScript 和 HTML 进行用户驱动的 Plotly

正如我们在“使用 Plotly 添加自定义控件”中看到的,Plotly 允许您在 Python 中添加自定义控件,如按钮或下拉菜单,并将其转换为由 JS 驱动的 HTML 控件。虽然这是 Plotly 的一个非常有用的功能,但在控件的布置和样式方面有些局限。更新 Plotly Web 图表的另一种方式是使用本地的 JS+HTML 控件来修改图表,过滤数据集或调整样式。事实证明这样做非常简单,并且借助一些 JS 技巧,可以实现更灵活、功能强大的控件设置。

让我们演示一些 JS 自定义控件,使用我们刚刚在“使用本地 JavaScript 创建 Plotly 图表”中构建的图表之一。我们将添加一个下拉菜单,允许用户更改显示的 x 轴组。两个明显的选项是按性别分组和按奖项类别分组的年龄。

首先,我们将在页面上添加一个 HTML 下拉菜单(select),并使用一些 flex-box CSS 将其居中:

<div class="main">
  <h1 class='title'>The Nobel Prize</h1>
  <h2>From notebook to the web with Plotly</h2>

  <!-- ... -->
  <div class="container">
  <!-- ... -->
    <div class="chart-wrapper">
      <div class='chart' id='violin-group'> </div>
    </div>
  </div>

  <div id="chart-controls">

    <div id="nobel-group-select-holder">
      <label for="nobel-group">Group:</label>
      <select name="nobel-group" id="nobel-group"></select> ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
    </div>

  </div>

</div>

1

这个select标签将包含通过 JS 和 D3 添加的option标签。

一些 CSS 将使controls容器中的任何控件居中,并适应字体样式:

#chart-controls {
  display: flex;
  justify-content: center;
  font-family: sans-serif;
  font-size: 0.7em;
  margin: 20px 0;
}

select {
  padding: 2px;
}

现在我们有了一些 HTML 基础,我们将使用一些 JS 和 D3 来为我们的组控件添加select标签。但首先,我们将调整 Plotly 绘图函数,以允许其根据新组更新。JSON 数据像以前一样导入,但现在数据存储为本地变量,并用于更新 Plotly 小提琴图。这个updateChart函数使用 Plotly 的update方法来创建图表。它类似于newPlot,但旨在在数据或布局变化时调用,高效地重新绘制图表以反映任何更改。

我们还有一个新的selectedGroup变量,它将用于下拉菜单select中,以更改正在绘制的字段:

let data
d3.json("data/nobel_winners.json").then((_data) => {
  console.log(_data);
  data = _data
  updateChart();
});

let selectedGroup = 'gender' ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)

function updateChart() {
  var traces = [
    {
      type: "violin",
      x: data.map((d) => d[selectedGroup]), ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
      y: data.map((d) => d.award_age),
      points: "none",
      box: {
        visible: true
      },
      line: {
        color: "green"
      },
      meanline: {
        visible: true
      }
    }
  ];

  var layout = {
    title: "Age distributions of the Nobel prizewinners",
    yaxis: {
      zeroline: false
    },
    xaxis: {
      categoryorder: 'category ascending' ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
    }
  };

  Plotly.update("violin-group", traces, layout); ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
}

1

selectedGroup变量允许用户通过下拉菜单select更改 x 轴组。

2

我们希望按字母顺序(从化学开始)呈现奖项组,因此对布局进行此更改。

3

newPlot的位置,我们调用update,它具有相同的签名,但用于反映数据(traces)或布局的更改。

使用updateChart方法,我们现在需要添加选择选项和回调函数,以在用户更改奖项组时调用:

let availableGroups = ['gender', 'category']
availableGroups.forEach((g) => {
  d3.select("#nobel-group") ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
    .append("option")
    .property("selected", g === selectedGroup) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
    .attr("value", g)
    .text(g);
});

d3.select("#nobel-group").on("change", function (e) {
  selectedGroup = d3.select(this).property("value"); ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
  updateChart();
});

1

对于每个可用的组,我们使用 D3 通过 ID 选择下拉菜单,并附加一个文本和值设置为组字符串的<option>标签。

2

这确保初始选择是selectedGroup的值,通过将selected属性设置为 true。

3

我们使用 D3 在进行选择时添加回调函数。在这里,我们获取选项(性别类别)的值,并将其用于设置selectedGroup变量。然后,我们更新图表以反映这一变化。

现在连接完成,我们有一个组下拉菜单,可以更改小提琴图以反映所选组。图 14-9 显示选择奖项类别组的结果。请注意,Plotly 聪明地旋转类别组标签,以防止重叠。

dpj2 1409

图 14-9. 添加下拉菜单以控制 Plotly

我们将在后面的章节中看到更多 HTML 控件的实际应用,其中演示了按钮和单选框。使用 JS 创建控件比 Python 驱动的选项更加灵活,但也需要一些网络开发技能。

概要

在本章中,我们看到如何将笔记本探索中的最佳图表转换为网络演示。可用选项很多,从静态 PNG 图像(也许带有一些额外的 Matplotlib 样式)到使用自定义 JavaScript 控件的交互式 Plotly 图表。数据可以嵌入在 Plotly 图表调用中,使用 Plotly 的离线库生成,或作为 JSON 字符串(适用于数据草图)或文件导入。

Plotly 是一个很好的图表库,通过学习 Python API,你基本上也学会了 JS API——这是一个很大的优势。对于传统和一些专业(例如机器学习)图表,它是一个很好的选择。对于稍微复杂或特别定制的内容,D3 提供了更多的功能,我们将在即将到来的章节中看到。

^(1) Bokeh是一个值得考虑的替代品。

^(2) 您可以使用 DataFrame 的T运算符轻松将数据框转置为所需的列形式。

^(3) 查看https://oreil.ly/zUyxK了解 Plotly 的线型选项。

^(4) 查看Plotly 网站获取一些演示。

^(5) 查看https://oreil.ly/8UDgA获取更多详细信息。

^(6) 对于更高级、用户驱动的大数据可视化,使用带有 API 的数据服务器是另一种选择。

第十五章:想象一个诺贝尔奖可视化

在第十三章中,我们探讨了诺贝尔奖数据集,寻找基于数据的吸引人和教育性的有趣故事。我们找到了一些有趣的信息,其中包括:

  • 玛丽亚·哥伯特,除玛丽·居里外唯一获得物理诺贝尔奖的女物理学家

  • 二战后美国诺贝尔奖激增,超过了英国、德国和法国这三个最大欧洲赢家的不断下降的计数

  • 大陆奖项分配的差异

  • 当奖项计数根据人口规模进行调整时,斯堪的纳维亚国家的主导地位

这些以及其他一些叙述需要特定类型的可视化。比较各国诺贝尔奖数量可能最好通过传统的条形图来实现,而地理奖项分布则需要地图。在本章中,我们将尝试设计一个现代的、交互式的可视化,其中包含我们在探索数据集时发现的一些关键故事。

为谁准备?

想象可视化时的第一个考虑因素是目标受众。一个用于在画廊或博物馆展示的可视化作品,与一个用于内部仪表板的可视化作品可能会有很大不同,即使它们可以使用相同的数据集。这本书预期的诺贝尔奖可视化作品的主要限制是要教授一些 D3 和 JavaScript 的关键子集,以便创建现代交互式网络可视化。这是一个相当非正式的数据可视化,旨在娱乐和传达信息。它不需要专业观众。

选择视觉元素

我们诺贝尔奖可视化的第一个限制是它必须足够简单,以教授并提供一组关键的 D3 技能。但即使没有这个限制,限制任何可视化的范围也可能是明智的。这个范围在很大程度上取决于上下文[¹],但是,与许多学习背景一样,少即是多。过多的互动会让用户不知所措,并削弱我们可能希望讲述的任何故事的影响力。

有了这个想法,让我们看看我们想要包括的关键元素以及这些元素如何被视觉上安排。

一种菜单栏是必不可少的,允许用户参与可视化并操作数据。它的功能将取决于我们选择讲述的故事,但肯定会提供一些方法来探索或过滤数据集。

理想情况下,可视化应该按年份显示每个奖项,并且当用户通过菜单栏精炼数据时,显示应该自动更新。考虑到国家和地区的趋势感兴趣,应包括一张地图,突出显示所选获奖国家,并显示其奖项数量的一些指示。柱状图是比较各国奖项数量的最佳方式,它也应根据数据变化动态调整。还应提供选择,可以根据各国的绝对奖项数量或人均奖项数量进行测量,考虑到各自的人口规模。

为了个性化可视化,我们应能够选择个别获奖者,展示任何可用的图片和我们从维基百科抓取的简短传记。这需要一个当前选定获奖者的列表,并在其中显示选定的个人。

上述元素提供了足够的范围来讲述我们在上一章节中发现的关键故事,稍加完善后应适合标准的表单因素。^(2)

我们的诺贝尔奖可视化对于所有设备使用固定大小,这意味着为了适应较小的设备(如上一代智能手机或平板电脑),必须牺牲具有更高分辨率的较大设备。对于许多可视化工作来说,固定大小为您提供了对视觉内容块、信息框、标签等具体放置的必要控制。对于一些特别是多元素仪表板的可视化,可能需要不同的方法。响应式网页设计(RWD)试图使您的可视化在特定设备上优化外观和感觉。一些流行的 CSS 库如Bootstrap会检测设备大小(例如,分辨率为 1,280×800 像素的平板电脑),并更改应用的样式表,以充分利用可用的屏幕空间。如果您需要对视觉元素的放置进行精确定位,则指定可视化的固定大小并在其中使用绝对定位是一种方法。然而,您应该意识到 RWD 的挑战,特别是在需要构建多组件仪表板等情况下。

现在让我们针对诺贝尔奖可视化的个别元素的外观、感觉和要求进行详细说明,从主要的用户控制元素菜单栏开始。

菜单栏

交互式可视化由用户从选项中选择、点击事物、操作滑块等驱动。这些允许用户定义可视化的范围,这就是为什么我们将首先处理它们的原因。我们的用户控件将显示为可视化顶部的工具栏。

驱动有趣发现的标准方式之一是允许用户按关键维度过滤数据。我们诺贝尔奖的显而易见的选项是类别、性别和国家,这是我们上一章探索的重点。这些过滤器应该是累积的,因此,例如,选择女性性别和物理学类别应该返回两位获奖女性物理学家。除了这些过滤器之外,我们应该有一个单选按钮,可以选择国家获奖者的绝对数量或人均数量。

图 15-1 显示了一个符合我们要求的菜单栏。放置在我们可视化的顶部,它具有选择器来过滤我们需要的维度,以及一个单选按钮来选择我们的国家获奖者指标,无论是绝对数量还是人均数量。

dpj2 1501

图 15-1. 用户控件

菜单栏将位于我们可视化的关键组件之上,显示所有诺贝尔奖项的时间轴。接下来让我们描述一下这个。

奖项按年份

上一章展示了诺贝尔奖在国家历史上的许多有趣趋势。我们还看到,尽管女性获奖者最近有所增加,但在科学领域远远落后。允许发现这些趋势的一种方式是在时间轴上显示所有诺贝尔奖,并提供一个筛选器,用于按性别、国家和类别选择奖项(使用刚讨论的菜单栏)。

如果我们将可视化设为 1,000 像素宽,那么在 114 年的奖项中,我们可以分配大约 8 像素给每个奖项,足以区分它们。任何一年获得的最高奖项数量为 14,即在 2000 年,这给了我们一个元素的最小高度为 8×14 像素,约为 120. 一个按类别编码的圆圈似乎是代表个别奖项的良好方式,这样我们得到的图表就像 图 15-2 中显示的那样。

dpj2 1801

图 15-2. 按年份彩色编码的诺贝尔奖时间线

个别奖项是可视化的本质,因此我们将这个时间轴显著地放在中心元素的上方,这个中心元素应该是一个地图,反映奖项的国际性质,并允许用户查看任何全球趋势。

显示选定诺贝尔奖国家的地图

映射是 D3 的一个强项,有许多全球投影可用,从经典的墨卡托到 3D 球形呈现。(3) 虽然地图显然很吸引人,但在呈现非地理数据时常常被过度使用且不合适。例如,除非你小心,大的地理区域,如欧洲国家或美国的州,往往会比人口更少的小地区更重要。在呈现人口统计信息时,难以避免这种偏差,可能导致误代表。(4)

但诺贝尔奖是国际性的,按大陆分配奖项是有意义的,因此使用全球地图来描绘筛选后的数据是一个很好的方法。如果我们在每个国家的中心叠加一个填充圆来反映奖项措施(绝对值或人均),那么我们就避免了对较大陆地的偏好。在欧洲,许多相对较小的国家按陆地面积划分,这些圆将相交。通过使它们略微透明,我们仍然可以看到叠加的圆,并通过添加不透明度来给出奖项密度的感觉。图 15-3 演示了这一点。

dpj2 1901

图 15-3. 奖项的全球分布

我们将为地图提供一个小工具提示,既作为演示如何构建这个方便的视觉组件的方式,也稍微帮助一下命名国家。图 15-4 展示了我们的目标。

dpj2 1906

图 15-4. 我们诺贝尔奖地图上的一个简单工具提示

较大元素的最后一个将放置在地图下方:一个条形图,允许用户清楚比较各国诺贝尔奖获奖人数。

一个显示各国获奖者数量的条形图

有很多证据表明条形图非常适合进行数字比较。可重新配置的条形图使我们的可视化具有很大的灵活性,使其能够呈现用户指导的数据过滤结果,选择度量标准(即绝对值与人均计数)等等。

图 15-5 显示了我们将用来比较所选国家获奖数量的条形图。轴上的刻度和条形应根据用户交互动态响应,由菜单栏驱动(参见图 15-1)。在条形图状态之间进行动画转换将是很好的,并且(正如我们将在“转换”中看到的那样)在 D3 中几乎是免费的。除了具有吸引力外,有理由认为这种转换也是有效的传达者。查看这篇斯坦福大学论文,了解有关数据可视化中动画转换有效性的一些见解。

dpj2 1505

图 15-5. 一个条形图组件

在地图和条形图的旁边,我们将放置一个当前选定获奖者列表和一个传记框,允许用户了解个别获奖者的情况。

选定获奖者的列表

我们希望用户能够选择个别获奖者,显示获奖者的简介和图片(如果有)。实现这一目标的最简单方法是使用一个列表框,显示当前选定的获奖者,通过菜单栏选择器从完整数据集中进行筛选。按年份降序排序这些获奖者是一个明智的默认设置。虽然我们可以允许列表按列排序,但这似乎是一种不必要的复杂化。

这里应该用一个简单的带列标题的 HTML 表格来完成任务。它会看起来像 图 15-6。

dpj2 1506

图 15-6. 选定获奖者的列表

列表将有可点击的行,允许用户选择要在我们的最后一个元素中显示的个别获奖者,一个小传记框。

一个带图片的迷你传记框

诺贝尔奖颁发给个人,每个人都有一个故事要讲。为了使我们的可视化更加人性化和丰富,我们应该使用我们从维基百科爬取的个别迷你传记和图片(参见 第 6 章)来展示从我们的列表元素中选择个别人员的结果。

图 15-7 展示了一个带有颜色顶部边框的传记框,指示奖项类别,颜色与我们的时间图 (图 15-2) 共享,右上角有照片(如果可用),以及维基百科传记条目的前几段。

dpj2 1507

图 15-7. 如果可用,显示选定获奖者的带图片的迷你传记

传记框完成了我们的视觉组件集。现在我们可以把它们放在指定的 1000×800 像素框架中。

完整的可视化

图 15-8 展示了我们完整的诺贝尔奖可视化,包括五个关键元素和顶部用户控件,排列在一个 1000×800 像素的框架中。因为我们决定时间线应该占据主要位置,全球地图则需要中心位置,其他元素自然排列。条形图需要额外的宽度来容纳 58 个国家的带标签的条形,而选定获奖者列表和迷你传记则完美地适合右侧。

dpj2 1601

图 15-8. 完整的诺贝尔奖可视化

在继续下一章之前,让我们总结一下我们的想象,看看如何实现它们。

总结

在本章中,我们想象了我们的诺贝尔奖可视化,确定了在上一章的探索中发现的关键故事所需的最小视觉元素。这些完美地融入了我们的完整作品,显示在 图 15-8 中。在接下来的章节中,我将向您展示如何构建各个元素,并如何将它们组合在一起形成现代的交互式 Web 可视化。我们将从介绍 D3 开始,通过一个简单的条形图的故事。

^(1) 专为专家设计的仪表板,可以容纳比通用教育可视化更多的功能。

^(2) 使用像素测量时,值得关注不断变化的设备分辨率。截至 2022 年 5 月,几乎所有设备都支持 1000×800 像素的可视化。

^(3) 这些 3D 正交投影在“假”意义上不使用 3D 图形上下文,比如 WebGL。从Jason Daviesobservablehq,和nullschool有一些不错的例子。

^(4) 查看xkcd获取一个例子。

^(5) 通过调整 RGBA 代码中的 alpha 通道,使用 CSS 属性opacity,从0(无)到1(全)。

^(6) 查看斯蒂芬·费的深思熟虑的博客文章

第十六章:构建可视化

在第十五章中,我们利用了我们对诺贝尔奖数据集的 pandas 探索的结果(参见第十一章),来想象一个可视化效果。图 16-1 展示了我们想象的可视化效果,在本章中,我们将看到如何构建它,利用 JavaScript 和 D3 的强大功能。

dpj2 1601

图 16-1. 我们的目标,诺贝尔奖可视化

我将展示我们构想的视觉元素如何结合,将我们新鲜清理和处理的诺贝尔数据集转换为交互式网络可视化,可以轻松部署到数十亿设备上。但在深入细节之前,让我们先看看现代网络可视化的核心组件。

准备工作

在开始构建诺贝尔奖可视化之前,让我们考虑将使用的核心组件以及如何组织我们的文件。

核心组件

正如我们在“一个基本页面带有占位符”中所看到的,构建现代网络可视化需要四个关键组件:

  • 一个 HTML 框架,用于支撑我们的 JavaScript 创建

  • 一个或多个 CSS 文件来控制数据可视化的外观和感觉

  • JavaScript 文件本身,包括可能需要的任何第三方库(D3 是我们最大的依赖项)

  • 最后但同样重要的是,将要转换的数据,理想情况下是 JSON 或 CSV 格式(如果是完全静态数据)

在我们开始查看数据可视化组件之前,让我们先为我们的诺贝尔奖可视化(Nobel-viz)项目准备好文件结构,并确定如何向可视化提供数据。

组织您的文件

示例 16-1 展示了我们项目目录的结构。按照惯例,我们在根目录下有一个index.xhtml文件,其中包含所有用于可视化的库和资产(图像和数据)的static目录。

示例 16-1. 我们的诺贝尔奖可视化项目的文件结构
nobel_viz
├── index.xhtml
└── static
    ├── css
    │   └── style.css
    ├── data          ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
    │   ├── nobel_winners_biopic.json
    │   ├── winning_country_data.json
    │   ├── world-110m.json
    │   └── world-country-names-nobel.csv
    ├── images
    │   └── winners  ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
    │       └── full
    │           ├── 002b4f05aa3758e2d6acadde4ed80aa991ed6357.jpg
    │           ├── 00d7ed381db8b5d18edc84694b7f9ce14ee57c5b.jpg
    │           ├── ...
    └── js                  ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
        ├── nbviz_bar.mjs
        ├── nbviz_core.mjs
        ├── nbviz_details.mjs
        ├── nbviz_main.mjs
        ├── nbviz_map.mjs
        ├── nbviz_menu.mjs
        └── nbviz_time.mjs
    └── libs
        ├── crossfilter.min.js
        ├── d3.min.js
        └── topojson.min.js

1

我们将使用的静态数据文件,包括 TopoJSON 世界地图(详见第十九章)和从网络上抓取的国家数据(详见“获取诺贝尔数据可视化的国家数据”)。

2

我们使用 Scrapy 爬取的诺贝尔奖获奖者的照片,详见“使用管道进行文本和图像爬取”。

3

js子目录包含我们的 Nobel-viz JavaScript 模块文件(.mjs),分为核心元素,并以*nbviz_*开头。

提供数据

我们在第六章中抓取的包含小传的完整诺贝尔数据集大约有三兆字节的数据,在网络传输时经过压缩后则显著减少。按照现代网页的标准,这并不算是一大量的数据。实际上,平均网页大小大约在 2MB 到 3MB 之间^(1)。尽管如此,它接近一个我们可能需要考虑将其分成更小的块以便根据需要加载的点。我们也可以使用像 SQLite 这样的数据库从 web 服务器动态提供数据(参见第十三章)。正如现在这样,初始等待时间的小不便被浏览器缓存所有数据后的高速性能所补偿。而且只需最初获取一次数据使事情变得简单多了。

对于我们的诺贝尔可视化,我们将从数据目录中提供所有数据(见示例 16-1,#2),在应用程序初始化时获取。

HTML 骨架

尽管我们的诺贝尔可视化有许多动态组件,但所需的 HTML 骨架却令人惊讶地简单。这展示了本书的一个核心主题——为了编程数据可视化,你并不需要非常传统的 web 开发技能。

index.xhtml 文件,在加载时创建可视化,在 示例 16-2 中展示。三个组成部分是:

  1. 导入了 CSS 样式表 style.css,设置字体、内容块位置等。

  2. HTML 占位符用 ID 形式为 nobel-[foo] 的视觉元素。

  3. JavaScript; 首先是第三方库,然后是我们的原始脚本。

我们将在接下来的章节详细介绍各个 HTML 部分,但我希望你能看到这个诺贝尔奖可视化的整体非编程元素。有了这个基本框架,你可以转向创意编程的工作,这正是 D3 鼓励和擅长的。当你习惯于在 HTML 中定义内容块,并使用 CSS 固定尺寸和定位时,你会发现你越来越多地花时间做你最喜欢的事情:用代码操纵数据。

小贴士

我发现将识别的占位符,如地图容器 <div id="nobel-map"></div>,视为其各自元素的所有权 是很有帮助的。我们在主 CSS 或 JS^(2) 文件中设置这些框架的维度和相对定位,而动态地图等元素则根据其框架的大小自适应。这允许非编程设计师通过 CSS 样式更改可视化的外观和感觉。

示例 16-2. 我们的单页可视化访问文件 index.xhtml
<!DOCTYPE html>
<meta charset="utf-8">
<title>Visualizing the Nobel Prize</title>
<!-- 1\. IMPORT THE visualization'S CSS STYLING -->
<link rel="stylesheet" href="static/css/style.css"
media="screen" />
<body>
  <div id='chart'>
    <!-- 2\. A HEADER WITH TITLE AND SOME EXPLANATORY INFO -->
    <div id='title'>Visualizing the Nobel Prize</div>
    <div id="info">
      This is a companion piece to the book <a href='http://'>
      Data visualization with Python and JavaScript</a>, in which
      its construction is detailed. The data used was scraped
      Wikipedia using the <a href=
      'https://en.wikipedia.org/wiki
 /List_of_Nobel_laureates_by_country'>
      list of winners by country</a> as a starting point. The
      accompanying GitHub repo is <a href=
      'http://github.com/Kyrand/dataviz-with-python-and-js-ed-2'>
      here</a>.
    </div>
    <!-- 3\. THE PLACEHOLDERS FOR OUR VISUAL COMPONENTS  -->
    <div id="nbviz">
      <!-- BEGIN MENU BAR -->
      <div id="nobel-menu">
        <div id="cat-select">
          Category
          <select></select>
        </div>
        <div id="gender-select">
          Gender
          <select>
            <option value="All">All</option>
            <option value="female">Female</option>
            <option value="male">Male</option>
          </select>
        </div>
        <div id="country-select">
          Country
          <select></select>
        </div>
        <div id='metric-radio'>
          Number of Winners:&nbsp;
          <form>
            <label>absolute
              <input type="radio" name="mode" value="0" checked>
            </label>
            <label>per-capita
              <input type="radio" name="mode" value="1">
            </label>
          </form>
        </div>
      </div>
      <!-- END MENU BAR  -->
      <!-- BEGIN NOBEL-VIZ COMPONENTS -->
      <div id='chart-holder' class='_dev'>
        <!-- TIME LINE OF PRIZES -->
        <div id="nobel-time"></div>
        <!-- MAP AND TOOLTIP -->
        <div id="nobel-map">
          <div id="map-tooltip">
            <h2></h2>
            <p></p>
          </div>
        </div>
        <!-- LIST OF WINNERS -->
        <div id="nobel-list">
          <h2>Selected winners</h2>
          <table>
            <thead>
              <tr>
                <th id='year'>Year</th>
                <th id='category'>Category</th>
                <th id='name'>Name</th>
              </tr>
            </thead>
            <tbody>
            </tbody>
          </table>
        </div>
        <!-- BIOGRAPHY BOX -->
        <div id="nobel-winner">
          <div id="picbox"></div>
          <div id='winner-title'></div>
          <div id='infobox'>
            <div class='property'>
              <div class='label'>Category</div>
              <span name='category'></span>
            </div>
            <div class='property'>
              <div class='label'>Year</div>
              <span name='year'></span>
            </div>
            <div class='property'>
              <div class='label'>Country</div>
              <span name='country'></span>
            </div>
          </div>
          <div id='biobox'></div>
          <div id='readmore'>
            <a href='#'>Read more at Wikipedia</a>
          </div>
        </div>
        <!-- NOBEL BAR CHART -->
        <div id="nobel-bar"></div>
      </div>
      <!-- END NOBEL-VIZ COMPONENTS -->
    </div>
  </div>
  <!-- 4\. THE JAVASCRIPT FILES -->
  <!-- THIRD-PARTY JAVASCRIPT LIBRARIES, MAINLY D3  -->
  <script src="libs/d3.min.js"></script>
  <!-- ... -->
  <!-- THE MAIN JAVASCRIPT MODULE FOR OUR NOBEL ELEMENTS -->
  <script src="static/js/nbviz_main.mjs" ></script>
</body>

HTML 框架(示例 16-2)定义了我们 Nobel-viz 组件的层次结构,但它们的视觉大小和定位是在 style.css 文件中设置的。在接下来的部分中,我们将看到这是如何完成的,并查看我们可视化的一般样式。

CSS 样式

我们将在各自的章节中处理我们图表中各个图表组件的样式(图 16-1)。本节将涵盖其余的非特定 CSS,最重要的是我们元素内容块(面板)的大小和定位。

可视化的大小是一个棘手的选择。现在有许多不同的设备格式,如智能手机、平板电脑、移动设备等,具有多种不同的分辨率,如“视网膜”^(3) 和全高清(1,920×1,080)。因此,像素尺寸比以前更加多样化,像素密度变得更具有意义。大多数设备执行像素缩放来进行补偿,这就是为什么您可以在智能手机上仍然阅读文字,即使它具有与大型桌面显示器相同数量的像素。此外,大多数手持设备具有捏缩放和平移功能,允许用户轻松关注较大数据可视化的区域。对于我们的 Nobel 数据可视化,我们将选择一个折衷的分辨率 1,280×800 像素,这在大多数桌面监视器上看起来应该还行,并且在移动设备的横向模式下可用,包括我们 50 像素高的顶部用户控件。

首先,我们使用 body 选择器设置了一些通用的样式,应用于整个文档;一个无衬线字体,浅白色背景和一些链接细节被指定。我们还设置了可视化的宽度和其边距:

body {
    font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
    background: #fefefe; ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
    width: 1000px;
    margin: 0 auto; /* top and bottom 0, left and right auto */
}

a:link {
    color: royalblue;
    text-decoration: none; ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
}

a:hover {
    text-decoration: underline;
}

1

这种颜色略偏白色(#ffffff),应有助于使页面稍微不那么亮,更易于眼睛。

2

我认为默认的下划线超链接看起来有点繁琐,因此我们去掉了装饰。

我们 Nobel-viz 有三个主要的 div 内容块,它们绝对定位在 #chart div 内(它们的相对父元素)。这些是主标题(#title)、可视化信息(#info)和主容器(#nbviz)。标题和信息是凭眼观察放置的,而主容器距页面顶部 90 像素,以便为它们留出空间,并且宽度设置为 100%以便扩展到可用空间。以下 CSS 实现了这一点:

#nbviz {
    position: absolute;
    top: 90px;
    width: 100%;
}

#title {
    position: absolute;
    font-size: 30px;
    font-weight: 100;
    top: 20px;
}

#info {
    position: absolute;
    font-size: 11px;
    top: 18px;
    width: 300px;
    right: 0px;
    line-height: 1.2;
}

chart-holder 的高度设置为 750 像素,宽度设置为其父元素的 100%,并具有 relativeposition 属性,这意味着其子面板的绝对定位将相对于其左上角。我们的图表底部填充了 20 像素:

#chart-holder {
    width: 100%;
    height: 750px;
    position: relative;
    padding: 0 0 20px 0; /* top right bottom left */
}

#chart-holder svg { ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
    width: 100%;
    height: 100%;
}

1

我们希望我们组件的 SVG 上下文能够扩展以适应它们的容器。

考虑到 Nobel-viz 的高度约束为 750 像素,我们的等经纬度地图的宽高比为二,^(4) 并且需要将 100 年以上的诺贝尔奖圆形指示器适应到我们的时间图表中,根据尺寸进行调整建议将 图 16-2 作为我们可视化元素大小的一个良好折衷。

dpj2 1602

图 16-2. Nobel-viz 的尺寸

此 CSS 样式按照 图 16-2 所示的位置和大小组件。

#nobel-map, #nobel-winner, #nobel-bar, #nobel-time, #nobel-list{
    position:absolute; ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
}

#nobel-time {
    top: 0;
    height: 150px;
    width: 100%; ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
}

#nobel-map {
    background: azure;
    top: 160px;
    width: 700px;
    height: 350px;
}

#nobel-winner {
    top: 510px;
    left: 700px;
    height: 240px;
    width: 300px;
}

#nobel-bar {
    top: 510px;
    height: 240px;
    width: 700px;
}

#nobel-list {
    top: 160px;
    height: 340px;
    width: 290px;
    left: 700px;
    padding-left: 10px; ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
}

1

我们希望绝对的手动调整位置,相对于 chart-holder 父容器。

2

时间轴占据了可视化的整个宽度。

3

您可以使用填充使组件有“呼吸空间”。

其他 CSS 样式特定于各个组件,并将在各自的章节中进行介绍。通过前面的 CSS,我们在 HTML 骨架上使用 JavaScript 来丰富我们的可视化。

JavaScript 引擎

在任何尺寸的可视化中,早期实施一些模块化是很好的。网上许多 D3 的例子^(5) 都是单页面解决方案,将 HTML、CSS、JS 甚至数据都结合在一个页面上。虽然这对通过示例教学很好,但随着代码库的增长,情况将迅速恶化,使得修改变得困难,并增加了命名空间冲突等问题的可能性。

导入脚本

我们使用 <script> 标签将 JavaScript 文件包含在我们入口的 index.xhtml 文件的 <body> 标签底部,如 示例 16-2 所示:

<!DOCTYPE html>
<meta charset="utf-8"> ... <body> ... <!-- THIRD-PARTY JAVASCRIPT LIBRARIES, MAINLY D3 BASED  --> ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
  <script src="static/libs/d3.min.js"></script>
  <script src="static/libs/topojson.min.js"></script>
  <script src="static/libs/crossfilter.min.js"></script>
  <!-- THE JAVASCRIPT FOR OUR NOBEL ELEMENTS -->
  <script src="static/js/nbviz_main.mjs"></script> ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
</body>

1

我们使用第三方库的本地副本。

2

我们诺贝尔应用的主入口点,它请求其第一个数据集并启动显示。该模块导入了可视化中使用的所有其他模块。

模块化的 JS 与导入

在本书的第一版中,为建立 nbviz 命名空间,以便在诺贝尔数据可视化的各个组件中放置函数、变量、常量等,采用了一种常见但相当巧妙的模式。这里是一个示例,因为您可能会在现实中遇到类似的模式:

/* js/nbviz_core.js
/* global $, _, crossfilter, d3  */ ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
(function(nbviz) {
     //... MODULES PRIVATE VARS ETC..
     nbviz.foo = function(){ //... ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
     };
}(window.nbviz = window.nbviz || {})); ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)

1

将变量定义为全局变量将防止它们触发JSLint 错误

2

将此函数作为共享 nbviz 命名空间的一部分暴露给其他脚本。

3

如果可用,则使用 nbviz 对象,并在没有时创建它。

每个 JS 脚本都用这种模式封装,所有必需的脚本都包含在主 index.xhtml<script> 标签中。随着跨浏览器支持 JS 模块的到来,我们有了一种更清洁、现代的方式来包含我们的 JavaScript,这对于任何 Pythonista 都是熟悉的(参见 “JavaScript Modules”)。

现在我们只需在 index.xhtml 中包含我们的主 JS 模块,这将导入所有其他所需的模块:

// static/js/nbviz_main.mjs import nbviz from './nbviz_core.mjs'
import { initMenu } from './nbviz_menu.mjs'
import { initMap } from './nbviz_map.mjs'
import './nbviz_bar.mjs' ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
import './nbviz_details.mjs'
import './nbviz_time.mjs'

1

这些被导入以初始化它们的更新回调。我们将在本章后面看到这是如何工作的。

在接下来的章节中,将详细解释用于生成可视化元素的 JavaScript/D3。首先,我们将处理数据从(数据)服务器流向客户端浏览器,并在客户端内部由用户交互驱动的 Nobel-viz 数据流。

基本数据流

处理任何复杂项目中的数据有许多方法。对于交互式应用程序,特别是数据可视化,我发现最健壮的模式是拥有一个中央数据对象来缓存当前数据。除了缓存的数据外,我们还有一些主数据对象中存储的活动反映或子集。例如,在我们的 Nobel-viz 中,用户可以选择数据的多个子集(例如,只有物理类别的获奖者)。

如果用户触发了不同的数据反映,比如选择每人均奖金指标,一个标志^(6)就会被设置(在这种情况下,valuePerCapita 被设置为 01)。然后我们更新所有的视觉组件,依赖于 valuePerCapita 的那些组件会相应地适应。地图指示器的大小会改变,柱状图会重新组织。

关键思想是确保视觉元素与用户驱动的数据变化同步。做到这一点的一种可靠方法是拥有一个单一的更新方法(这里称为 onDataChange),每当用户执行某些操作以更改数据时就调用此方法。该方法通知所有活动的视觉元素数据已更改,它们会相应地做出响应。

现在让我们看看应用程序的代码如何配合,从共享的核心工具开始。

核心代码

第一个加载的 JavaScript 文件是 nbviz_core.js。该脚本包含了我们可能希望在其他脚本中共享的任何代码。例如,我们有一个 categoryFill 方法,为每个类别返回特定的颜色。这被时间线组件使用,并作为传记框中的边框。这个核心代码包括我们可能想要隔离以进行测试的函数,或者只是为了使其他模块更清晰。

提示

在编程中经常使用字符串常量作为字典键、比较项和生成的标签。在需要时输入这些字符串很容易养成坏习惯,但更好的方法是定义一个常量变量。例如,不使用'if option === "All Categories"',而是使用'if option === nbviz.ALL_CATS'。在前者中,误输'All Categories'不会引发错误,这是一场意外。拥有const还意味着只需编辑一次即可更改所有字符串的出现。JavaScript 有一个新的const关键字,使得强制常量变得更容易,尽管它仅阻止变量被重新赋值。参见Mozilla 文档获取一些示例和const限制的详细说明。

示例 16-3 展示了在其他模块之间共享的代码。任何打算供其他模块使用的内容都附加在共享的nbviz命名空间上。

示例 16-3. 在 nbviz_core.js 中的共享代码库
let nbviz = {}
nbviz.ALL_CATS = 'All Categories'
nbviz.TRANS_DURATION = 2000 // time in ms for our visual transitions nbviz.MAX_CENTROID_RADIUS = 30
nbviz.MIN_CENTROID_RADIUS = 2
nbviz.COLORS = { palegold: '#E6BE8A' } // any named colors used 
nbviz.data = {} // our main data store nbviz.valuePerCapita = 0 // metric flag nbviz.activeCountry = null
nbviz.activeCategory = nbviz.ALL_CATS

nbviz.CATEGORIES = [
    "Chemistry", "Economics", "Literature", "Peace",
    "Physics", "Physiology or Medicine"
];
// takes a category like Physics and returns a color nbviz.categoryFill = function(category){
    var i = nbviz.CATEGORIES.indexOf(category);
    return d3.schemeCategory10[i]; ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
};

let nestDataByYear = function(entries) {          ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
//... };

nbviz.makeFilterAndDimensions = function(winnersData){
//... };

nbviz.filterByCountries = function(countryNames) {
//... };

nbviz.filterByCategory = function(cat) {
//... };

nbviz.getCountryData = function() {
// ... };

nbviz.callbacks = []
nbviz.onDataChange = function () { ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
  nbviz.callbacks.forEach((cb) => cb())
}

export default nbviz ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/4.png)

1

我们使用 D3 的内置颜色方案来提供奖项类别颜色。schemeCategory10是一个包含 10 个颜色十六进制码的数组(['#1f77b4', '#ff7f0e',...]),我们通过类别索引来访问。

2

此处和以下的空方法将在接下来的章节中根据使用情境进行详细解释。

3

当数据集更改时(在应用程序初始化后,这是用户驱动的),调用此函数更新诺贝尔可视化元素。按顺序调用由组件模块设置并存储在callbacks数组中的更新回调,触发任何必要的视觉变化。详见“基本数据流”了解详细信息。

4

nbviz对象具有实用函数、常量和变量,是该模块的默认导出项,由其他模块导入,因此使用import nbviz from ​./⁠nbviz_core

有了核心代码,让我们看看如何使用 D3 的实用方法初始化我们的应用程序来获取静态资源。

初始化诺贝尔奖可视化

为了启动应用程序,我们需要一些数据。我们使用 D3 的jsoncsv辅助函数加载数据并将其转换为 JavaScript 对象和数组。使用Promise.all^(7)方法同时发起这些数据获取请求,等待所有四个请求都完成,然后将数据传递给指定的处理函数,在本例中为ready

// static/js/nbviz_main.mjs //...
  Promise.all( ![1
    d3.json('static/data/world-110m.json'),
    d3.csv('static/data/world-country-names-nobel.csv'),
    d3.json('static/data/winning_country_data.json'),
    d3.json('static/data/nobel_winners_biopic.json'),
  ]).then(ready)

  function ready([worldMap, countryNames, countryData, winnersData]) { ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
    // STORE OUR COUNTRY-DATA DATASET
    nbviz.data.countryData = countryData
    nbviz.data.winnersData = winnersData
    //... }

1

同时发起对四个数据文件的请求。这些静态文件包括一个世界地图(110m 分辨率)和一些我们将在可视化中使用的国家数据。

2

返回给ready的数组使用JavaScript 解构将顺序数据分配给相应的变量。

如果我们的数据请求成功,ready函数将接收所请求的数据,并准备好向可视元素发送数据。

准备就绪

在由Promise.all方法发起的延迟数据请求解决后,它调用指定的ready函数,并按添加顺序将数据集作为参数传递。

ready函数在示例 16-4 中展示。如果数据下载没有错误,我们将使用获奖者数据创建一个活动过滤器(由 Crossfilter 库提供),用于允许用户根据类别、性别和国家选择诺贝尔获奖者的子集。然后调用一些初始化方法,最后使用onDataChange方法触发数据可视化元素的绘制,更新条形图、地图、时间线等。图 16-3 中的示意图展示了数据变化传播的方式。

示例 16-4. 当初始数据请求已解决时调用ready函数
//...
  function ready([worldMap, countryNames, countryData, winnersData]) {
    // STORE OUR COUNTRY-DATA DATASET
    nbviz.data.countryData = countryData
    nbviz.data.winnersData = winnersData
    // MAKE OUR FILTER AND ITS DIMENSIONS
    nbviz.makeFilterAndDimensions(winnersData) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
    // INITIALIZE MENU AND MAP
    initMenu()
    initMap(worldMap, countryNames)
    // TRIGGER UPDATE WITH FULL WINNERS' DATASET
    nbviz.onDataChange()
  }

1

该方法利用新加载的诺贝尔奖数据集创建我们将用于允许用户选择要可视化的数据子集的过滤器。详见“Filtering Data with Crossfilter”章节。

我们将在介绍 Crossfilter 库中的“Filtering Data with Crossfilter”章节时看到makeFilterAndDimensions方法(示例 16-4,1)的工作原理。暂时假设我们有一种方法通过一些菜单选择器(例如选择所有女性获奖者)获取用户当前选择的数据。

dpj2 1603

图 16-3. 应用程序的主要数据流

数据驱动更新

ready函数中初始化菜单和地图(我们将在各自的章节中详细讨论其工作原理:第十九章讨论地图,第二十一章讨论菜单)后,我们使用nbviz_core.js中定义的onDataChange方法触发可视元素的更新。onDataChange(参见示例 16-5)是一个共享函数,当显示的数据集因用户交互而改变,或者用户选择不同的国家奖励度量(例如按人均测量而不是绝对数字)时调用。

示例 16-5. 当选定数据更改时调用的函数以更新可视元素
// nbviz_core.js nbviz.callbacks = [] ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)

nbviz.onDataChange = function () {
  nbviz.callbacks.forEach((cb) => cb()) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
}

1

需要更新的每个组件模块都将其回调追加到此数组中。

2

数据变化时,依次调用组件回调函数,触发任何必要的视觉变化以反映新数据。

当模块首次导入时,它们将它们的回调添加到核心模块中的callbacks数组中。例如,这是条形图:

// nbviz_bar.mjs import nbviz from './nbviz_core.mjs'
// ... nbviz.callbacks.push(() => {
  let data = nbviz.getCountryData()
  updateBarChart(data) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
})

1

当主要的核心更新函数调用此回调函数时,国家数据由本地更新函数使用以更改条形图。

主要数据集由getCountryData方法生成,该方法通过国家将获奖者分组,并添加一些国家信息,即人口大小和国际字母代码。示例 16-6 详细介绍了此方法。

示例 16-6。创建主要的国家数据集
nbviz.getCountryData = function() {
    var countryGroups = nbviz.countryDim.group().all(); ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)

    // make main data-ball
    var data = countryGroups.map( function(c) { ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
        var cData = nbviz.data.countryData[c.key]; ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
        var value = c.value;
        // if per capita value then divide by pop. size
        if(nbviz.valuePerCapita){
            value = value / cData.population; ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/4.png)
        }
        return {
            key: c.key, // e.g., Japan
            value: value, // e.g., 19 (prizes)
            code: cData.alpha3Code, // e.g., JPN
        };
    })
        .sort(function(a, b) { ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/5.png)
            return b.value - a.value; // descending
        });

    return data;
};

1

countryDim是我们 Crossfilter 维度之一(见“使用 Crossfilter 进行数据过滤”),在这里提供组键、值计数(例如,{key:Argentina, value:5})。

2

我们使用数组的map方法来创建一个新数组,并从我们的国家数据集中添加组件。

3

使用我们的组键(例如,澳大利亚)获取国家数据。

4

如果valuePerCapita单选开关打开,则我们将奖项数量除以该国家的人口数量,从而得到一个更公平的相对奖项计数。

5

使用Arraysort方法使数组按值降序排列。

我们的 Nobel-viz 元素的更新方法都利用 Crossfilter 库过滤的数据。现在让我们看看如何做到这一点。

使用 Crossfilter 进行数据过滤

Crossfilter 是由 D3 的创作者 Mike Bostock 和 Jason Davies 开发的一个高度优化的库,用于使用 JavaScript 探索大型、多变量数据集。它非常快速,并且可以轻松处理远比我们的诺贝尔奖数据集大得多的数据集。我们将使用它来根据类别、性别和国家的维度来过滤我们的获奖者数据集。

选择 Crossfilter 有些雄心勃勃,但我想展示它的实际效果,因为我个人发现它非常有用。它也是dc.js,这个非常流行的 D3 图表库的基础,这证明了它的实用性。虽然 Crossfilter 在开始交叉维度过滤时可能有些难以理解,但大多数用例遵循一个基本模式,很快就能掌握。如果你发现自己试图切割和分析大型数据集,Crossfilter 的优化将会是一大帮助。

创建过滤器

在初始化 Nobel-viz 时,nbviz_core.js 中定义的 makeFilterAndDimensions 方法被从 nbviz_main.js 中的 ready 方法中调用(参见“Ready to Go”)。makeFilterAndDimensions 使用刚刚加载的诺贝尔奖数据集创建一个 Crossfilter 过滤器和一些基于它的维度(例如,奖项类别)。

我们首先使用初始化时获取的诺贝尔奖获得者数据集创建我们的过滤器。让我们再次看看那是什么样子:

[{
  name:"C\u00e9sar Milstein",
  category:"Physiology or Medicine",
  gender:"male",
  country:"Argentina",
  year: 1984
 },
 {
  name:"Auguste Beernaert",
  category:"Peace",
  gender:"male",
  country:"Belgium",
  year: 1909
 },
 ...
}];

要创建我们的过滤器,请使用获奖者对象的数组调用 crossfilter 函数:

nbviz.makeFilterAndDimensions = function(winnersData){
    // ADD OUR FILTER AND CREATE CATEGORY DIMENSIONS
    nbviz.filter = crossfilter(winnersData);
    //...
};

Crossfilter 的工作原理是允许您在数据上创建维度过滤器。您可以通过将函数应用于对象来这样做。在最简单的情况下,这将创建一个基于单一类别的维度,例如按性别划分。在这里,我们创建了将用于过滤诺贝尔奖的性别维度:

nbviz.makeFilterAndDimensions = function(winnersData){
//...
    nbviz.genderDim = nbviz.filter.dimension(function(o) {
        return o.gender;
    });
//...
}

这个维度现在通过性别字段对我们的数据集进行了高效的排序。我们可以像这样使用它,来返回所有性别为女性的对象:

nbviz.genderDim.filter('female'); ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
var femaleWinners = nbviz.genderDim.top(Infinity); ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
femaleWinners.length // 47

1

filter 接受一个单一值或者在适当情况下,一个范围(例如,[5, 21]—所有在 5 和 21 之间的值)。它也可以接受值的布尔函数。

2

一旦应用了过滤器,top 就会返回指定数量的排序对象。指定 Infinity^(9) 将返回所有过滤后的数据对象。

当我们开始应用多维度过滤器时,Crossfilter 真正发挥了作用,使我们能够将数据切片并分割成我们需要的任何子集,所有这些都以令人印象深刻的速度实现。^(10)

让我们清除性别维度并添加一个新的维度,通过获奖类别进行过滤。要重置维度,^(11) 不带参数地应用 filter 方法:

nbviz.genderDim.filter();
nbviz.genderDim.top(Infinity) //  the full Array[858] of objects

我们现在将创建一个新的奖项类别维度:

nbviz.categoryDim = nbviz.filter.dimension(function(o) {
    return o.category;
});

现在我们可以按顺序过滤性别和类别维度,从而找到例如所有女性物理学奖获得者:

nbviz.genderDim.filter('female');
nbviz.categoryDim.filter('Physics');
nbviz.genderDim.top(Infinity);
// Out:
// [
//  {name:"Marie Sklodowska-Curie", category:"Physics",...
//  {name:"Maria Goeppert-Mayer", category:"Physics",...
// ]

请注意,我们可以有选择地打开和关闭过滤器。因此,例如,我们可以移除物理类别过滤器,这意味着性别维度现在包含所有女性诺贝尔奖获得者:

nbviz.categoryDim.filter();
nbviz.genderDim.top(Infinity); // Array[47] of objects

在我们的诺贝尔可视化中,这些过滤操作将由用户从最顶部的菜单栏进行选择驱动。

除了返回过滤后的子集,Crossfilter 还可以对数据执行分组操作。我们使用这个来获取柱状图和地图指示器的国家奖聚合数据:

nbviz.genderDim.filter(); // reset gender dimension var countryGroup = nbviz.countryDim.group(); ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
countryGroup.all(); ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)

// Out: //  //  {key:"Argentina", value:5}, ![3
//  {key:"Australia", value:9}, //  {key:"Austria", value:14}, // ...]

1

Group 接受一个可选的函数作为参数,但默认通常是您想要的。

2

返回所有按键和值分组的组。不要修改返回的数组。^(12)

3

value 是阿根廷诺贝尔奖获得者的总数。

要创建我们的 Crossfilter 过滤器和维度,我们使用在 nbviz_core.js 中定义的 makeFilterAndDimensions 方法。Example 16-7 显示了整个方法。请注意,创建过滤器的顺序并不重要——它们的交集仍然相同。

Example 16-7. 制作我们的 Crossfilter 过滤器和维度
    nbviz.makeFilterAndDimensions = function(winnersData){
        // ADD OUR FILTER AND CREATE CATEGORY DIMENSIONS
        nbviz.filter = crossfilter(winnersData);
        nbviz.countryDim = nbviz.filter.dimension(function(o){ ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
            return o.country;
        });

        nbviz.categoryDim = nbviz.filter.dimension(function(o) {
            return o.category;
        });

        nbviz.genderDim = nbviz.filter.dimension(function(o) {
            return o.gender;
        });
    };

1

我们使用完整的 JavaScript 函数来进行教学,但现在可能会使用缩短的形式:o => o.country

运行诺贝尔奖可视化应用程序

要运行诺贝尔奖可视化,我们需要一个能够访问根 index.xhtml 文件的 Web 服务器。为了开发目的,我们可以利用 Python 内置的 http 模块来启动所需的服务器。在包含我们的索引文件的根目录中,运行:

$ python -m http.server 8080
Serving HTTP on 0.0.0.0 port 8080 ...

现在打开浏览器窗口,转到 http:localhost:8080,您应该会看到图 16-4。

dpj2 1604

图 16-4. 完成的 Nobel-viz 应用程序

总结

在本章中,我们概述了如何在第十五章中想象的可视化中实现。主干由 HTML、CSS 和 JavaScript 构建块组装而成,并描述了应用程序中的数据馈送和数据流。在接下来的章节中,我们将看到我们的 Nobel-viz 的各个组件如何使用发送到它们的数据来创建我们的交互式可视化。我们将从一个大的章节开始,介绍 D3 的基础知识,同时展示如何构建我们应用程序的条形图组件。这应该为你后续的 D3 焦点章节做好准备。

^(1) 请查看这些 SpeedCurveWeb Almanac 的帖子,了解平均网页大小的一些分析。

^(2) 我建议将 JavaScript 样式保存给特殊场合,尽可能地使用纯 CSS。

^(3) 目前约为 2,560×1,600 像素。

^(4) 请参阅“投影”以比较不同的几何投影。考虑到展示所有获得诺贝尔奖的国家的约束条件,等经纬投影效果最好。

^(5) 请查看D3 的 GitHub 上的集合

^(6) 在我们的应用程序中,我尽可能地保持简单;随着 UI 选项数量的增加,将标志、范围等存储在专用对象中是明智的选择。

^(7) 您可以在Mozilla 文档中了解更多关于 Promise.all 的信息。

^(8) 请参阅这个Square 页面,这是一个令人印象深刻的示例。

^(9) JavaScript 的 Infinity 是表示无穷大的数值。

^(10) Crossfilter 被设计用于实时更新数百万条记录,以响应用户输入。

^(11) 这将清除此维度上的所有过滤器。

^(12) 请参阅 Crossfilter GitHub 页面

第十七章:介绍 D3—​柱状图的故事

在第十六章中,我们通过将诺贝尔奖可视化分解为组成部分来设想它。在本章中,我将通过展示如何构建我们所需的柱状图(参见图 17-1)温柔地向您介绍 D3。

dpj2 1701

图 17-1. 本章目标柱状图

D3 不仅仅是一个图表库。它是用来构建图表库等其他工具的库。那么为什么我通过传统的柱状图来向您介绍 D3 呢?首先,因为从头开始为其制作一个,对于第一次完全控制图表的外观和感觉,并且不受特定图表库的偏见的经验,应该会有一些小小的兴奋。其次,因为这恰好是学习 D3 的基本要素的绝佳方式,特别是数据连接和输入-输出-移除更新模式,现在由 D3 的新 join 方法很好地封装。如果您掌握了这些基础知识,您将能够运用 D3 提供的全部力量和表现力,创造出比柱状图更有创意的东西。

我们将使用一些在第四章中涵盖的 Web 开发技术,特别是 D3 的专长之一:SVG 图形(请参见“可伸缩矢量图形”)。您可以使用像CodePenVizHub这样的在线编辑器尝试代码片段(VizHub 还有大量精选的数据可视化示例)。

在我们开始构建柱状图之前,让我们考虑它的各个元素。

定位问题

柱状图有三个关键组成部分:轴、图例和标签,当然还有柱形。由于我们正在制作一个现代化、交互式的柱状图组件,我们需要轴和柱形能够根据用户交互进行转换—特别是通过顶部选择器筛选得奖者集合(见图 15-1)。

我们将逐步构建图表,最终使用 D3 过渡效果,这可以使您的 D3 创作更加引人入胜和吸引人。但首先我们将介绍 D3 的基础知识:

  • 在你的网页中选择 DOM 元素

  • 获取和设置它们的属性、属性和样式

  • 追加和插入 DOM 元素

在这些基础知识牢固的基础上,我们将继续探讨数据绑定的乐趣,这是 D3 开始发挥其作用的地方。

处理选择集

选择是 D3 的支柱。使用类似于 jQuery 的 CSS 选择器,D3 可以选择和操作单个和分组的 DOM 元素。所有的 D3 链式操作都是从使用 selectselectAll 方法选择 DOM 元素或元素集开始的。select 返回第一个匹配的元素;selectAll 返回匹配的元素集。

图 17-2 显示了使用 selectselectAll 方法的 D3 选择示例。这些选择用于更改一个或多个条形图的 height 属性。select 方法返回具有类 bar 的第一个 rect(ID barL),而 selectAll 可以根据提供的查询返回任意组合的 rect

dpj2 1702

图 17-2. 选择元素并更改属性:使用初始 HTML 构建了三个矩形。然后进行选择,并调整了一个或多个条的高度属性。

除了设置属性(DOM 元素上的命名字符串;例如,idclass),D3 还允许您设置元素的 CSS 样式、属性(例如,复选框是否选中)、文本和 HTML。

图 17-3 显示了使用 D3 更改 DOM 元素的所有方法。通过这些少数方法,您可以实现几乎任何外观和感觉。

dpj2 1703

图 17-3. 使用 D3 更改 DOM 元素

图 17-4 显示了我们如何通过向元素添加类或直接设置样式来应用 CSS 样式。我们首先通过其 ID barM 选择中间的条形图。然后使用 classed 方法来应用黄色高亮(参见 CSS)并将 height 属性设置为 50 像素。接着使用 style 方法直接将条形图填充为红色。

dpj2 1704

图 17-4. 设置属性和样式

D3 的 text 方法设置适用 DOM 标签的文本内容,例如 divph* 标题和 SVG 文本元素。要看到 text 方法的实际效果,让我们创建一个带有一些 HTML 的小标题占位符:

<!DOCTYPE html>
<meta charset="utf-8">

<style>font-family: sans-serif;</style>

<body>
  <h2 id="title">title holder</h2>
</body>

图 17-5(之前)显示了生成的浏览器页面。

现在让我们创建一个 fancy-title 的 CSS 类,具有大号和粗体字体:

.fancy-title {
    font-size: 24px;
    font-weight: bold;
}

现在我们可以使用 D3 选择标题头,向其添加 fancy-title 类,并将其文本设置为“我的条形图”:

d3.select('#title')
  .classed('fancy-title', true)
  .text('My Bar Chart');

图 17-5(之后)显示了生成的放大和加粗的标题。

dpj2 1705

图 17-5. 使用 D3 设置文本和样式

除了设置 DOM 元素的属性之外,我们还可以使用选择来获取这些属性。在 图 17-3 中列出的方法中省略第二个参数,可以获取有关网页设置的信息。

图 17-6 显示了如何从 SVG 矩形中获取关键属性。正如我们将看到的那样,从 SVG 元素获取像 widthheight 这样的属性对于程序化的适应和调整非常有用。

dpj2 1706

图 17-6. 获取 rect 条形图的详细信息

图 17-7 演示了htmltext获取方法。创建一个小列表(ID 为silly-list)后,我们使用 D3 选择它并获取各种属性。html方法返回列表子<li>标签的 HTML,而text方法返回列表中包含的文本,去除了 HTML 标签。请注意,对于父标签,返回的任何文本格式有些混乱,但对于一两个字符串搜索可能已足够。

dpj2 1707

图 17-7. 从list标签获取 HTML 和文本

到目前为止,我们一直在操作现有 DOM 元素的属性、样式和属性。这是一个有用的技能,但当我们开始使用其appendinsert方法以编程方式创建 DOM 元素时,D3 变得更加强大。现在让我们来看看这些。

添加 DOM 元素

我们已经看到如何选择和操作 DOM 元素的属性、样式和属性。现在我们将看到 D3 如何允许我们附加和插入元素,以编程方式调整 DOM 树。

我们将从一个包含nobel-bar占位符的小 HTML 框架开始:

<!DOCTYPE html>
<meta charset="utf-8">
<link rel="stylesheet" href="style.css" />

<body>
  <div id='nobel-bar'></div>

  <script
    src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.3.1/d3.min.js">
  </script>
  <script type="text/javascript" src="script.js"></script> ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
</body>

1

script.js文件是我们将添加条形图 JavaScript 代码的地方。

让我们通过一些 CSS 设置nobel-bar元素的大小,放在style.css中:

#nobel-bar {
  width: 600px;
  height: 400px;
}

.bar {
    fill: blue; /* blue bars for the chapter */
}

通常,使用 D3 创建图表时,首先要为其提供一个 SVG 框架。这涉及将一个<svg>画布元素附加到一个div chartholder 上,然后将一个<g>组附加到<svg>上以容纳特定的图表元素(在我们的情况下是图表条)。这个组有边距来容纳轴、轴标签和标题。

按照惯例,您将在一个margin对象中指定图表的边距,然后使用该对象和图表容器的 CSS 指定宽度和高度来推导出图表组的宽度和高度。所需的 JavaScript 看起来像是示例 17-1。

示例 17-1. 获取我们条形图的尺寸
    var chartHolder = d3.select("#nobel-bar");

    var margin = {top:20, right:20, bottom:30, left:40};

    var boundingRect = chartHolder.node()
      .getBoundingClientRect(); ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
    var width = boundingRect.width - margin.left - margin.right,
    height = boundingRect.height - margin.top - margin.bottom;

1

获取我们 Nobel 条形图面板的边界矩形,用它来设置其条形容器组的宽度和高度。

有了我们条形图组的宽度和高度,我们使用 D3 来构建图表的框架,附加所需的<svg><g>标签,并指定 SVG 画布的大小和条形图组的平移:

d3.select('#nobel-bar').append("svg")
    .attr("width", width + margin.left + margin.right)
    .attr("height", height + margin.top + margin.bottom)
    .append("g").classed('chart', true)
    .attr("transform", "translate(" + margin.left + ","
                                  + margin.top + ")");

这改变了nobel-bar内容块的 HTML:

...
    <div id="nobel-bar">
      <svg width="600" height="400">
        <g class="chart" transform="translate(40, 20)"></g>
      </svg>
    </div>
...

结果的 SVG 框架显示在图 17-8 中。<svg>元素的宽度和高度是其子组和周围边距的总和。使用transform偏移子组,将其向右平移margin.left像素,并向下平移margin.top像素(根据 SVG 约定,向正 y 方向)。

dpj2 1708

图 17-8. 构建我们的条形图框架

使用append添加几个条形图,我们将使用一些虚拟数据:一个包含诺贝尔奖获得国家顶级奖项数量的对象数组。

var nobelData = [
    {key:'United States', value:336},
    {key:'United Kingdom', value:98},
    {key:'Germany', value:79},
    {key:'France', value:60},
    {key:'Sweden', value:29},
    {key:'Switzerland', value:23},
    {key:'Japan', value:21},
    {key:'Russia', value:19},
    {key:'Netherlands', value:17},
    {key:'Austria', value:14}
];

要构建一个粗糙的条形图,^1 我们可以遍历nobelData数组,在我们前进的同时向图表组附加一根条。示例 17-2 演示了这一点。在为图表构建基本框架之后,我们遍历nobelData数组,使用value字段设置条的高度和 y 位置。图 17-9 显示了如何使用对象值向我们的图表组添加条。请注意,因为 SVG 使用向下的 y 轴,您必须通过条形图的高度减去条形图的高度来放置条形图以正确放置。正如我们将在后面看到的,通过使用 D3 的比例尺,我们可以限制这种几何上的记账工作。

示例 17-2。使用append构建简单的条形图
var buildCrudeBarchart = function() {

    var chartHolder = d3.select("#nobel-bar");

    var margin = {top:20, right:20, bottom:30, left:40};
    var boundingRect = chartHolder.node().getBoundingClientRect();
    var width = boundingRect.width - margin.left - margin.right,
    height = boundingRect.height - margin.top - margin.bottom;
    var barWidth = width/nobelData.length;

    var svg = d3.select('#nobel-bar').append("svg")
        .attr("width", width + margin.left + margin.right)
        .attr("height", height + margin.top + margin.bottom)
        .append("g").classed('chart', true)
        .attr("transform", "translate(" + margin.left + ","
        + margin.top + ")");

    nobelData.forEach(function(d, i) { ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
        svg.append('rect').classed('bar', true)
            .attr('height', d.value)
            .attr('width', barWidth)
            .attr('y', height - d.value)
            .attr('x', i * (barWidth));
    });
};

1

遍历nobelData中的每个对象,forEach方法向匿名函数提供对象和数组索引。

dpj2 1709

图 17-9。使用 D3 编程基本条形图

D3 向 DOM 树添加元素的另一种方法是使用其insert方法。insert的工作方式类似于append,但添加了第二个选择器参数,允许您在标签序列中的特定位置之前插入元素,例如在有序列表的开头。图 17-10 演示了insert的使用:选择silly-list中的列表项就像append一样,然后第二个参数(例如,:first-child)指定要插入的元素。

dpj2 1710

图 17-10。使用 D3 的insert方法添加列表项

对于 SVG 元素,使用 x 和 y 坐标直接定位在其父组中,insert可能看起来是多余的。但是,正如在“分层和透明度”中讨论的那样,DOM 的顺序在 SVG 中很重要,因为元素是分层的,这意味着 DOM 树中的最后一个元素覆盖了之前的任何元素。我们将在第十九章中看到一个例子,我们在世界地图上有一个网格叠加物(或graticule)。我们希望这个网格在所有其他地图元素之上绘制,因此使用insert将这些元素放在它之前。

我们在图 17-9 中的粗糙条形图需要一点改进。让我们看看如何通过 D3 强大的scale对象和 D3 最大的想法——数据绑定来改善事物。

利用 D3

在示例 17-2 中,我们使用 D3 构建了一个基本的简单条形图。这个图表有很多问题。首先,循环遍历数据数组有些笨拙。如果我们想要调整数据集以适应我们的图表,该怎么办?我们需要一些方法来根据响应添加或删除条形,并使用新数据更新结果条形并重新绘制一切。我们还需要保持在 x 和 y 上的条形尺寸比例以反映不同数量的条形和不同的最大条形值。这已经是相当多的繁琐工作了,事情可能会很快变得混乱。此外,我们应该把变化的数据集保存在哪里?对我们的图表进行任何数据驱动的更改都需要传递数据集,然后构建一个循环来迭代元素。感觉数据存在于链式 D3 工作流之外,而实际上它需要成为其不可分割的一部分。

优雅地将我们的数据集与 D3 集成的解决方案在于数据绑定的概念,这是 D3 的最大想法。比例尺问题由 D3 最有用的实用程序库之一解决:比例尺。现在我们将看看这些,并释放 D3 的力量进行数据绑定。

使用 D3 比例尺进行测量

D3 比例尺背后的基本思想是将输入域映射到输出范围。这一简单的过程可以消除构建图表、可视化等过程中的许多烦琐细节。随着您对比例尺的熟悉程度越来越高,您会发现越来越多的情况可以应用它们。掌握它们是轻松使用 D3 的关键组成部分。

D3 提供了许多比例尺,将它们分为三大类:定量、序数和时间^(2)比例尺。有各种各样的映射来适应几乎所有可能的情况,但您可能会发现自己大部分时间使用线性和序数比例尺。

在使用中,D3 的比例尺可能看起来有些奇怪,因为它们既是对象的一部分,又是函数的一部分。这意味着,在创建比例尺后,您可以调用它的各种方法来设置其属性(例如domain用于设置其域),但您也可以将其作为一个带有域参数的函数来返回一个范围值。下面的例子应该能清楚地说明区别:

var scale = d3.scaleLinear(); // create a linear scale scale.domain([0, 1]).range([0, 100]); ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
scale(0.5) // returns 50 ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)

1

我们使用比例尺的domainrange方法,将 0 到 1 映射到 0 到 100。

2

我们将像一个带有域参数0.5的函数称为比例尺,返回一个范围值50

让我们来看看两种主要的 D3 比例尺,定量和序数,看看我们如何在构建条形图时使用它们。

定量比例尺

使用 D3 的定量比例尺时,通常用于构建折线图、柱状图、散点图等,是linear,将连续的域映射到连续的范围上。例如,我们希望柱子的高度是nobelData值的线性函数。要映射的值范围在柱子的最大和最小高度之间(400 像素到 0 像素),映射的域从最小的可行值(0)到数组中的最大值(336 位美国获奖者)。在下面的代码中,我们首先使用 D3 的max方法获取nobelData数组中的最大值,用于指定我们的域的结束位置:

let maxWinners = d3.max(nobelData, function(d){
                   return +d.value; ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
                 });

let yScale = d3.scaleLinear()
                 .domain([0, maxWinners]) /* [0, 336] */
                 .range([height, 0]);

1

如果有可能,就像 JSON 编码数据通常那样,值是一个字符串,用*+*前缀将其强制转换为数字。

要注意的一个小技巧是,我们的范围从其最大值开始减少。这是因为我们希望使用它来指定沿 SVG 向下 y 轴的正向位移,以使柱状图的 y 轴向上指向(即,柱子越小,所需的 y 位移就越大)。反之,您可以看到最大的柱子(美国的获奖者统计)根本没有位移(见图 17-11)。

dpj2 1711

图 17-11。使用 D3 的线性比例尺来固定柱状图的 y 轴的域和范围

我们为柱状图的 y 轴使用了最简单的线性比例尺,从一个数值范围映射到另一个数值范围,但是 D3 的线性比例尺可以做更多。理解这一点的关键是 D3 的interpolate方法。^(3)它接受两个值并返回它们之间的插值器。因此,在我们的yScale的范围中,在图 17-11 中,interpolate返回 400 和 0 的数值插值器:

var numInt = d3.interpolate(400, 0);

numInt(0); //   400 ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
numInt(0.5); // 200 numInt(1); //   0

1

插值器默认的域是[0,1]。

interpolate方法不仅可以处理数字,还可以处理字符串、颜色代码甚至对象。您还可以为您的域数组指定多于两个数字——只需确保域和范围数组的大小相同。(4)我们可以结合这两个事实创建一个有用的色彩图:(5)

var color = d3.scaleLinear()
    .domain([-1, 0, 1])
    .range(["red", "green", "blue"]);

color(0) // "#008000" green's hex code
color(0.5) // "004080" slate blue

D3 的线性比例尺有许多有用的实用方法和丰富的功能。数值映射可能是您的主力比例尺,但我建议阅读D3 文档以充分了解线性比例尺的灵活性。在该网页上,您将找到 D3 的其他定量比例尺,几乎可以适应每一个定量的场合:

  • 功率比例尺,类似于线性但具有指数变换(例如,sqrt)。

  • 对数比例尺,类似于线性但具有对数变换。

  • 量化标度,线性的变体,具有离散范围;即使输入是连续的,输出也被分成段或桶(例如,[1, 2, 3, 4, 5])。

  • 分位数标度,通常用于颜色调色板,与量化标度类似,但具有离散或桶装的域以及范围。

  • 同一域和范围的恒等标度(相当神秘)。

量化标度非常适合处理连续值,但通常我们需要基于离散域(例如名称或类别)获取值。D3 具有一组专门的序数标度来满足这种需求。

序数标度

序数标度将一个值数组作为其域,并将这些值映射到离散或连续的范围,为每个值生成一个映射值。要明确创建一对一映射,我们使用标度的 range 方法:

var oScale = d3.scaleOrdinal()
               .domain(['a', 'b', 'c', 'd', 'e'])
               .range([1, 2, 3, 4, 5]);

oScale('c'); // 3

在我们的条形图案例中,我们希望将一个索引数组映射到连续范围,以提供条的 x 坐标。为此,我们可以使用带状标度 scaleBandrangerangeRound 方法,后者将输出值四舍五入到单个像素。在这里,我们使用 rangeRound 将数字数组映射到连续范围,并将输出值四舍五入为整数像素值:

var oScale = d3.scaleBand()
               .domain([1, 2, 3, 4, 5])
               .rangeRound([0, 400]);

oScale(3) // 160
oScale(5) // 320

在构建我们最初的粗略条形图(示例 17-2)时,我们使用了 barWidth 变量来调整条的大小。实现条之间的填充需要一个填充变量和对 barWidth 以及条位置的必要调整。使用我们新的序数带状标度,我们免费获得这些内容,避免了繁琐的记账工作。调用 xScalebandwidth 方法提供了计算出的条宽度。我们还可以使用标度的 padding 方法指定条之间的填充为每个条所占空间的一部分。bandwidth 值会相应调整。以下是一些实际应用示例:

var oScale = d3.scaleBand()
               .domain([1, 2]); ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)

oScale.rangeRound([0, 100]); ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
oScale(2); // 50 oScale.bandwidth(); // 50 

oScale.rangeRound([0, 100]);
oScale.padding(0.1) // pBpBp ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
oScale(1); // 5 oScale(2); // 52 oScale.bandwidth(); // 42, the padded bar width

1

存储标度并固定域;如果我们预期范围会改变,这很有用。

2

rangeRound 将输出值四舍五入为整数。

3

我们指定一个填充因子(p)为0.1 * 分配给条形图(B)空间。

图 17-12 显示了我们条形图的带状 x 轴标度,填充因子为0.1。连续范围为 600(像素),即条形图的宽度,域是表示各个条的整数数组。如图所示,为 xScale 提供一个条的索引号将返回其在 x 轴上的位置。

dpj2 1712

图 17-12. 设置我们条形图 x 轴标度的域和范围,使用填充因子为 0.1。

拥有我们的 D3 标度后,让我们转向 D3 的核心概念,将数据绑定到 DOM 以便驱动对其的更改。

利用数据绑定/连接释放 D3 的强大功能

D3 代表数据驱动文档,到目前为止我们还没有真正利用我们的数据进行驾驶。为了释放 D3 的潜力,我们需要接受它的核心思想,即将数据集中的数据与其对应的 DOM 元素进行绑定或连接(在线上两个术语都有使用),并基于此集成更新网页(文档)。这一小步骤将数据与 DOM 元素结合起来,与最强大的 D3 方法enterexit结合使用时,将会产生巨大的功能性。

经过多次迭代,D3(5 版及以上)现在提供了join方法,大大简化了enterexit的使用。本章将重点介绍join方法。

要解读在线上成千上万使用旧的enterexitremove更新模式的示例,有助于了解 D3 数据连接背后的原理。详细内容请参见附录 A。

使用数据更新 DOM

我认为可以公平地说,使用 D3 来更新 DOM 以显示新数据在历史上并不容易理解(注:当您尝试教授它或撰写有关它的书籍章节时,您真的会很感慨)。过去有许多实现,比如通用更新模式,它们本身也经历了许多不兼容的形式。这意味着很多网络上流行的例子,使用旧版本的 D3,将会引导您走向错误的方向。

提示

尽管您可能预计只构建一次性图表,但通过单一数据绑定过程,养成问自己“如果我需要动态更改数据会怎样?”的习惯是很好的。如果答案不立即显而易见,您可能实施了一个糟糕的 D3 设计。及时发现这一点意味着您可以进行一些代码审计并做出必要的更改,以免事情开始恶化。养成这种习惯是好事,因为 D3 在某种程度上是一种手艺技能,不断重申最佳实践将在您需要时得到回报。

最新版本的 D3 已经巩固了基本方法,并且使它们更加简单。因此,使用enterexitremove这三个关键的 D3 方法来进行数据连接现在可以通过单个join方法来完成,这个方法有合理的默认设置。在本节中,我们将看到如何使用这四种方法来响应新数据更新我们的条形图,这里以诺贝尔奖获得国家为例。

可能 D3 背后最基本的概念是数据连接的概念。本质上,数据集(通常是数据对象的数组)用于创建一些视觉元素,例如条形图的矩形条。对这些数据的任何更改都会反映在可视化效果的变化上,例如条形图中的条数,或现有条形图的高度或位置。我们可以将这个操作分为三个阶段:

  1. 使用enter为任何没有视觉元素的数据创建一个视觉元素。

  2. 更新这些元素的属性和样式,如果需要,也更新任何现有元素。

  3. 使用exitremove方法移除任何不再与数据关联的旧视觉元素。

以往,D3 要求您自行实现更新模式,使用enterexit和(简要地)merge方法,而新的join方法将这些方法结合到一个用户友好的包中。通常您可以只用一个参数调用它,指定要与数据关联的视觉元素(例如 SVG rect 或 circle),但它也具有更精细的控制,允许您传入enterexitupdate回调函数。

看看现在通过将一些由 SVG 矩形构成的水平条形图与我们的虚拟诺贝尔数据集进行关联,关联数据和视觉元素有多容易。我们将以下数据集与矩形组进行关联,并用它创建一些水平条形图。您可以在CodePen中找到一个工作代码示例。

let nobelData = [
  { key: "United States", value: 336 },
  { key: "United Kingdom", value: 98 },
  { key: "Germany", value: 79 },
  { key: "France", value: 60 },
  { key: "Sweden", value: 29 },
  { key: "Switzerland", value: 23 },
  { key: "Japan", value: 21 }
];

我们将使用一些 HTML 和 CSS 创建一个 SVG 组来放置条形图,并使用蓝色填充创建一个 bar 类:

<div id="nobel-bars">
  <svg width="600" height="400">
    <g class="bars" transform="translate(40, 20)"></g>
  </svg>
</div>

<style>
.bar {
  fill: blue;
}
</style>

带有数据和 HTML 结构的支撑,让我们看看 D3 的join方法的实际效果。我们将创建一个updateBars函数,它将接受一个键-值国家数据数组,并将其与一些 SVG 矩形进行关联。

updateBars函数接受一个数据数组,并首先使用data方法将其添加到类为'bar'的选择集中。如 Example 17-3 所示,然后使用join方法将此bars选择集与一些 SVG 矩形进行关联。

示例 17-3。将我们的国家数据与一些 SVG 条形图进行关联
function updateBars(data) {
  // select and store the SVG bars group
  let svg = d3.select("#nobel-bars g");
  let bars = svg.selectAll(".bar").data(data);

  bars
    .join("rect") ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
    .classed("bar", true) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
    .attr("height", 10)
    .attr("width", d => d.value)
    .attr("y", function (d, i) {
      return i * 12;
    });
}

1

这将所有现有的条形图数据与 SVG rect元素进行关联。

2

join返回所有现有的rect,然后我们使用它们的关联数据更新它们。

调用join方法后,D3 正在进行明智的操作,使用enterexitremove来保持数据和视觉元素的同步。我们通过几次使用变化数据调用updateBars函数来演示这一点。首先,我们将切片我们诺贝尔数据集的前四个成员,并用它们来更新条形图:

updateBars(nobelData.slice(0, 4));

这产生了这里显示的条形图:

dpj2 17in01

现在让我们更新数据关联,仅使用诺贝尔数据数组的前两个成员:

updateBars(nobelData.slice(0, 2));

dpj2 17in02

调用此方法会产生前图像中显示的两个条形图。在幕后,D3 的记账系统已经移除了多余的矩形,这些矩形与较小的数据集不再关联任何数据。

现在让我们反向操作,看看如果我们使用更大的数据集会发生什么,这次是诺贝尔数组的前六个成员:

updateBars(nobelData.slice(0, 6));

dpj2 17in03

再次,D3 做了预期的事情(参见前一图像),这次向新数据对象追加新的矩形。

已经演示了 D3 的join成功地保持数据和视觉元素的同步,根据需要添加和删除矩形,我们现在有了构建诺贝尔柱状图的基础。

组装柱状图

现在让我们把本章学到的内容整合起来,构建我们柱状图的主要元素。我们将在这里充分利用 D3 的比例尺。

首先,我们将通过 ID #nobel-bar 选择我们柱状图的容器,并使用其尺寸(来自boundingClientRectangle)和一些边距设置来获取图表的宽度和高度:

let chartHolder = d3.select('#nobel-bar')
let margin = { top: 20, right: 20, bottom: 35, left: 40 }
let boundingRect = chartHolder.node().getBoundingClientRect()
let width = boundingRect.width - margin.left - margin.right,
height = boundingRect.height - margin.top - margin.bottom
// some left-padding for the y-axis label
var xPaddingLeft = 20

现在我们将使用宽度和高度设置我们的比例尺:

let xScale = d3.scaleBand()
  .range([xPaddingLeft, width]) // left-padding for y-label
  .padding(0.1)

let yScale = d3.scaleLinear().range([height, 0])

现在我们将使用宽度、高度和边距创建 SVG 图表组,并将其存储到一个变量中:

var svg = chartHolder
    .append('svg')
    .attr('width', width + margin.left + margin.right)
    .attr('height', height + margin.top + margin.bottom)
    .append('g')
    .attr('transform', 'translate(' + margin.left + ',' + margin.top + ')')

在我们的 HTML 和 SVG 框架就位之后,让我们调整updateBars函数(参见示例 17-3)以响应我们真实的诺贝尔数据变化。更新函数将接收一个数据数组,格式如下:

[ {key: 'United States', value: 336, code: 'USA'}
  {key: 'United Kingdom', value: 98, code: 'GBR'}
  {key: 'Germany', value: 79, code: 'DEU'} ... ]

当使用新数据调用updateBarchart函数时,首先会过滤掉任何获奖数量为零的国家,并更新 x 和 y 比例尺的域,以反映柱子/国家的数量和最大获奖数,如示例 17-4 所示(#update_bar_chart)。

示例 17-4. 更新柱状图
let updateBarChart = function (data) {
  // filter out any countries with zero prizes by value
  data = data.filter(function (d) {
    return d.value > 0
  })
  // change the scale domains to reflect the newly filtered data
  // this produces an array of country codes: ['USA', 'DEU', 'FRA' ...]
  xScale.domain(
    data.map(d => d.code)
  )
  // we want to scale the highest number of prizes won, e.g., USA: 336
  yScale.domain([
    0,
    d3.max(data, d => d.value)
  ])
// ...
}

通过更新比例尺,我们可以使用数据连接创建所需的柱子。这与示例 17-3 中显示的功能本质上相同,但使用比例尺调整柱子的大小,并使用定制的entry方法向新创建的柱子添加类和左填充:

let bars = svg
     .selectAll('.bar')
     .data(data)
     .join(
       (enter) => { ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
         return enter
           .append('rect')
           .attr('class', 'bar')
           .attr('x', xPaddingLeft)
       }
     )
     .attr('x', d => xScale(d.code))
     .attr('width', xScale.bandwidth())
     .attr('y', d => yScale(d.value))
     .attr('height', d => height - yScale(d.value))

1

我们定制enter方法,向矩形添加bar`类。请注意,我们需要在join调用后返回enter对象以便使用。

现在我们有了一个根据数据变化响应的柱状图,本例中由用户启动。过滤所有化学奖数据显示结果见图 17-13。尽管缺少一些关键元素,但是构建柱状图的重要工作已完成。现在让我们添加轴和一些酷炫的过渡效果,为其添加最后的润色。

dpj2 1713

图 17-13. 最终柱状图

轴和标签

现在我们有了可用的更新模式,我们将添加轴和轴标签,这是任何体面柱状图所需的。

D3 并未提供很多高级图表元素,鼓励您自行构建。但它确实提供了一个方便的axis对象,可以减少手工创建 SVG 元素的工作量。它易于使用,并且与我们的数据更新模式完美配合,允许轴刻度和标签根据呈现的数据变化而变化。

为了定义我们的 x 和 y 轴,我们需要知道我们希望轴代表的范围和定义域。在我们的情况下,它与我们的 x 和 y 比例尺的范围和定义域相同,因此我们将这些供应给轴的scale方法。D3 轴还允许您指定它们的方向,这将固定刻度线和刻度标签的相对位置。对于我们的条形图,我们希望 x 轴位于底部,y 轴位于左侧。我们的序数 x 轴将为每个条形图设置一个标签,但对于 y 轴,刻度数的选择是任意的。十个看起来是一个合理的数字,所以我们使用ticks方法设置它。以下代码显示了如何声明我们的条形图的轴:

let xAxis = d3.axisBottom().scale(xScale)

let yAxis = d3
  .axisLeft()
  .scale(yScale)
  .ticks(10)
  .tickFormat(function (d) {
    if (nbviz.valuePerCapita) { ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
      return d.toExponential()
    }
    return d
  })

1

我们希望我们的刻度标签的格式与我们选择的度量标准(人均或绝对)一起改变。人均会产生一个非常小的数字,最好用指数形式表示(例如,0.000005 → 5e-6)。tickFormat方法允许您获取每个刻度的数据值并返回所需的刻度字符串。

我们还需要一些 CSS 来正确地样式化轴,移除默认的fill,设置描边颜色为黑色,并使形状呈现得清晰。我们还将在这里指定字体大小和字体系列:

/* style.css */
.axis { font: 10px sans-serif; }
.axis path, .axis line {
    fill: none;
    stroke: #000;
    shape-rendering: crispEdges;
}

现在我们有了我们的axis生成器,我们需要一些 SVG 组来容纳它们生成的轴。让我们将这些作为具有合理类名的组添加到我们的主svg选择器中:

svg.append("g")
    .attr("class", "x axis")
    .attr("transform", "translate(0," + height + ")"); ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)

svg.append("g")
        .attr("class", "y axis");

1

根据 SVG 的约定,y 从顶部向下测量,因此我们希望我们底部导向的 x 轴从图表的顶部向下移动height像素。

我们条形图的轴具有固定的范围(图表的宽度和高度),但它们的定义域将随用户筛选数据集而变化。例如,如果用户按经济学类别过滤数据,(国家)条的数量将减少:这将更改序数 x 比例尺(条数)和定量 y 比例尺(最大获奖者数)的定义域。我们希望显示的轴随这些变化的定义域变化,并进行良好的过渡。

示例 17-5 展示了如何更新轴。首先,我们使用新数据更新我们的比例尺定义域(A)。这些新的比例尺定义域在调用它们各自轴组上的轴生成器时反映出来。

示例 17-5. 更新我们的条形图的轴
let updateBarChart = function(data) {
    // A. Update scale domains with new data
    xScale.domain( data.map(d => d.code) );
    yScale.domain([0, d3.max(data, d => +d.value)])
    // B. Use the axes generators with the new scale domains
    svg.select('.x.axis')
        .call(xAxis) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
        .selectAll("text") ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
        .style("text-anchor", "end")
        .attr("dx", "-.8em")
        .attr("dy", ".15em")
        .attr("transform", "rotate(-65)");

    svg.select('.y.axis')
        .call(yAxis);
    // ... }

1

调用 D3 的axis在我们的 x 轴组元素上构建所有必要的轴 SVG,包括刻度和刻度标签。D3 的axis使用内部更新模式来实现对新绑定数据的过渡。

2

创建 x 轴后,我们对生成的文本标签进行了一些 SVG 操作。首先,我们选择了轴的text元素,刻度标签。然后,我们将它们的文本锚点放置在元素的末端,并稍微移动它们的位置。这是因为文本是围绕其锚点旋转的,而我们希望围绕现在位于刻度线下方的国家标签的末端旋转。我们操作的结果显示在图 17-16 中。请注意,如果不旋转标签,它们将彼此合并。

dpj2 1716

图 17-16。x 轴上重新定向的刻度标签

现在我们有了工作的坐标轴,让我们给 x 轴添加一个小标签,然后看看条形图如何处理我们的真实数据:

let xPaddingLeft = 20 ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)

let xScale = d3.scaleBand()
  .range([xPaddingLeft, width])
  .padding(0.1)
//... svg.append("g")
    .attr("class", "y axis")
    .append("text")
    .attr('id', 'y-axis-label')
    .attr("transform", "rotate(-90)") ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
    .attr("y", 6)
    .attr("dy", ".71em") ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
    .style("text-anchor", "end")
    .text('Number of winners');

1

左填充常数,以像素为单位,以为 y 轴标签让路。

2

将文本逆时针旋转到正立的位置。

3

dy是相对坐标[相对于刚刚指定的y坐标(6)]。通过使用em单位(相对于字体大小),我们可以对文本边距和基线进行方便的调整。

图 17-17 显示了我们为化学奖获得者数据集应用分类选择器过滤器后的结果。条形宽度增加以反映国家数量的减少,并且两个坐标轴都适应了新数据集。

dpj2 1717

图 17-17。我们应用化学奖获得者分类筛选器前后的诺贝尔奖条形图

现在我们有了一个工作的条形图,使用更新模式来根据用户驱动的数据集调整自身。但是,尽管它功能齐全,但是响应数据更改的转换在视觉上仍然很突然,甚至令人不适。使变化更具吸引力甚至具有信息性的一种方式是在短时间内持续更新图表,使保留的国家条从其旧位置移动到新位置,同时调整其高度和宽度。这种持续的转换确实为可视化添加了生命,并且在许多最令人印象深刻的 D3 作品中都可以看到。好消息是,转换已经紧密集成到 D3 的工作流程中,这意味着您可以以几行代码的成本实现这些酷炫的视觉效果。

转换

就目前而言,我们的条形图功能完善。它通过添加或删除条形元素以及使用新数据更新它们来响应数据变化。但是,从一种数据反映到另一种数据反映的立即变化会感到有点生硬和视觉上的不协调。

D3 的过渡效果提供了平滑更新元素的能力,使它们在设定的时间段内持续变化。这既可以美观,有时还能提供信息。^(7) 重要的是,D3 的过渡效果能够极大地提升用户的参与感,这已经足够成为掌握它们的理由了。

图 17-18 展示了我们要达到的效果。当使用新选择的数据集更新条形图时,我们希望存在于过渡前后的任何国家的条形图能够平滑地从旧位置和尺寸过渡到新位置和尺寸,如图中的法国条形图在过渡过程中逐渐增长,大约几秒钟,其间的条形图宽度和高度逐渐增加。随着 x 和 y 轴比例尺的变化,轴的刻度和标签也会相应调整。

dpj2 1718

图 17-18. 更新时的平滑条形图过渡效果

如图 17-18 所示的效果出奇地容易实现,但需要理解 D3 中数据绑定的精确方式。默认情况下,当新数据绑定到现有的 DOM 元素时,是通过数组索引进行的。如图 17-19 所示,使用我们选定的条形图作为示例。第一个条形图(B0),之前绑定了美国的数据,现在绑定了法国的数据。它保持在第一位置并更新其大小和刻度标签。实质上,美国的条形图变成了法国的。^(9)

dpj2 1719

图 17-19. 默认情况下,新数据通过索引进行关联

为了在我们的过渡中实现连续性(例如,使美国的条形图移动到其新位置同时改变其新的高度和宽度),我们需要将新数据绑定到一个唯一的键而不是索引。D3 允许您将函数作为data方法的第二个参数,该函数从对象数据中返回一个键,用于将新数据绑定到正确的条形图上,假设它们仍然存在。如图 17-20 所示。现在,第一个条形图(0)与新的美国数据绑定在一起,通过索引改变其位置以及其宽度和高度为新的美国条形图。

dpj2 1720

图 17-20. 使用对象键连接新数据

通过键值连接数据可以为我们的国家条形图提供正确的起始点和终点。现在我们只需找到一种方法,在它们之间实现平滑的过渡。我们可以通过使用 D3 最酷的方法之一,即transitionduration,来做到这一点。在我们改变条形图的尺寸和位置属性之前调用这些方法,D3 就会神奇地在它们之间实现平滑过渡,如图 17-18 所示。在我们的条形图更新中添加过渡效果只需要几行代码:

// nbviz_core.mjs nbviz.TRANS_DURATION = 2000 // time in milliseconds // nbviz_bar.mjs import nbviz from ./nbviz_core.mjs
//... svg.select('.x.axis')
    .transition().duration(nbviz.TRANS_DURATION) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
    .call(xAxis) //... //... svg.select('.y.axis')
    .transition().duration(nbviz.TRANS_DURATION)
    .call(yAxis);
//... var bars = svg.selectAll(".bar")
    .data(data, d => d.code) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
//... let bars = svg.selectAll('.bar')
  .data(data, (d) => d.code)
  .join(
   // ...
  )
  .classed('active', function (d) {
    return d.key === nbviz.activeCountry
  })
  .transition()
  .duration(nbviz.TRANS_DURATION)
  .attr("x", (d) => xScale(d.code)) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
  .attr("width", xScale.bandwidth())
  .attr("y", (d) => yScale(d.value))
  .attr("height", (d) => height - yScale(d.value));

1

一个持续时间为两秒的过渡,即我们的 TRANS_DURATION 常量为 2000 (ms)。

2

使用数据对象的 code 属性进行连续数据连接。

3

xywidthheight 属性将平滑地从当前值变形为这里定义的值。

过渡效果将作用于现有 DOM 元素的大多数明显属性和样式。^(10)

刚刚展示的过渡效果可以使属性平稳地从起始点变化到目标点,但是 D3 允许对这些效果进行大量调整。例如,您可以使用 delay 方法指定过渡开始前的时间。这个延迟也可以是数据的一个函数。

可能最有用的额外过渡方法是 ease,它允许您指定在过渡期间更新元素属性的方式。默认的缓动函数是 CubicInOut,但您也可以指定像 quad 这样的东西,它会随着过渡的进行加速,或者像 bounceelastic 这样的东西,它们基本上做它们名字上说的事情,给变化带来有弹性的感觉。还有 sin,它在开始时加速,朝结束时减速。参见 easings.net 以了解不同的缓动函数的详细描述,以及 observablehq 以获取 D3 缓动函数的全面运行说明,辅以交互式图表。

如果 D3 提供的缓动函数不符合您的需求,或者您感觉特别雄心勃勃,那么像大多数 D3 的东西一样,您可以自己编写以满足任何微妙的要求。 tween 方法提供了您可能需要的精细控制。

通过工作中基于 join 的更新模式和一些酷炫的过渡效果,我们完成了我们的诺贝尔奖条形图。总是有改进的空间,但这个条形图将超出工作要求。在继续研究我们的诺贝尔奖可视化的其他组件之前,让我们总结一下这一相当大的章节中我们学到的东西。

更新条形图

当导入条形图模块时,它将一个回调函数附加到核心模块中的回调数组中。当以用户交互响应方式更新数据时,将调用此回调函数,并使用新的国家数据更新条形图:

nbviz.callbacks.push(() => { ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
  let data = nbviz.getCountryData()
  updateBarChart(data)
})

1

当数据更新时,在核心模块中调用这个匿名函数。

概要

这是一个庞大且相当具有挑战性的章节。D3 不是最容易学习的库,但我通过将事物分解成可消化的部分来平缓学习曲线。花些时间吸收基本思想,关键是开始为自己设定一些小目标,以扩展你的 D3 知识。我认为 D3 在很大程度上是一种艺术形式,比大多数库更是如此,你会在实践中学习。

理解和有效应用 D3 的关键要素是更新模式和涉及的数据绑定。如果你在基本水平上理解了这一点,D3 的其他大部分花哨的功能将很容易上手。专注于 dataenterexitremove 方法,并确保你真正理解发生了什么。这是从 D3 编程的大多数“复制粘贴”风格中进步的唯一途径,虽然最初是有效的(因为有很多酷炫的例子),但最终会令人沮丧。使用浏览器的开发者工具控制台(目前 Chrome 和 Chromium 在这方面拥有最佳工具)检查 DOM 元素,查看通过 __data__ 变量绑定的数据。如果与你的预期不符,通过找出原因来学习会让你收获良多。

你现在应该对 D3 的核心技术有了相当扎实的基础。在下一章中,我们将挑战这些新技能,制作一个更为雄心勃勃的图表,即我们的诺贝尔奖时间线。

^(1) 当我们将国家的诺贝尔奖获奖次数从绝对值改为人均值时,显示的大量移动,随着国家柱形图改变顺序,强调了两种指标之间的差异。

^(2) 详细列表请见D3 的 GitHub 页面

^(3) 完整详情请参阅D3 文档

^(4) D3 将截取较大者。

^(5) D3 拥有许多内置的颜色地图和复杂的颜色处理,包括 RGB、HCL 等。我们将在接下来的几章中看到其中一些实际应用。

^(6) 坐标轴遵循了迈克·博斯托克在Towards Reusable Charts中提出的类似模式,利用 JavaScript 对象的 call 方法在所选 DOM 元素上构建 HTML。

^(7) 例如,当我们将诺贝尔奖的国家获奖情况从绝对值改为人均值时,国家柱形图的大量移动强调了两种度量标准之间的差异。

^(8) 在动画和计算机图形领域,这种效果被称为tweening(参见此 Wikipedia 页面)。

^(9) 请查看迈克·博斯托克在他的网站上对对象恒定性的精彩演示。

^(10) 过渡效果仅适用于现有元素——例如,你不能淡入创建的 DOM 元素。但是,你可以通过使用 opacity CSS 样式来实现淡入淡出效果。

第十八章:可视化个别奖项

在第十七章中,您学习了 D3 的基础知识,如何选择和更改 DOM 元素,如何添加新元素以及如何应用数据更新模式,这是交互式 D3 的核心。在本章中,我将扩展您迄今所学,并向您展示如何构建一个相当新颖的视觉元素,显示每年的所有个别诺贝尔奖(图 18-1)。这个诺贝尔奖时间线将使我们扩展上一章的知识,演示许多新技术,包括更高级的数据操作。

dpj2 1502

图 18-1. 本章的目标图表,诺贝尔奖的时间线

让我们首先展示如何为我们的时间线图表构建 HTML 框架。

构建框架

我们目标图表的构建方式类似于上一章详细介绍的诺贝尔奖条形图。我们首先使用 D3 选择带有 ID nobel-time<div> 容器,然后使用容器的宽度和高度以及指定的边距来创建我们的 svg 图表组:

import nbviz from './nbviz_core.mjs'

let chartHolder = d3.select('#nobel-time');

let margin = {top:20, right:20, bottom:30, left:40};
let boundingRect = chartHolder.node()
  .getBoundingClientRect();
let width = boundingRect.width - margin.left
- margin.right,
height = boundingRect.height - margin.top - margin.bottom;

let svg = chartHolder.append("svg")
        .attr("width", width + margin.left + margin.right)
        .attr("height", height + margin.top
        + margin.bottom)
        .append('g')
          .attr("transform",
                  "translate(" + margin.left + ","
                  + margin.top + ")");
    // ...
})

在我们的 svg 图表组准备好之后,让我们添加比例尺和轴线。

比例尺

为了放置圆形指示器,我们使用了两个序数比例尺(示例 18-1)。x 比例尺使用 rangeRoundBands 方法指定圆形之间的 10%填充。由于我们使用 x 比例尺设置圆形的直径,因此我们的 y 比例尺的范围高度手动调整以容纳所有指示器,允许它们之间略有填充。我们使用 rangeRoundPoints 来将坐标舍入到整数像素。

示例 18-1. 图表的两个序数带比例尺,用于 x 和 y 轴
let xScale = d3.scaleBand()
  .range([0, width])
  .padding(0.1) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
  .domain(d3.range(1901, 2015))

let yScale = d3.scaleBand()
  .range([height, 0]).domain(d3.range(15)) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)

1

我们使用了 0.1 的填充因子,大约是指示器直径的 10%。

2

域是[0, …​, 15],15 是任何一年中颁发的历史最高奖项数。

与上一章的条形图不同,此图的范围和域都是固定的。 xScale 的域是诺贝尔奖设立的年份,而 yScale 的域是从零到任意一年中最大奖项数(例如 2000 年的 14 个奖项)。这两者都不会因用户交互而改变,因此我们在 update 方法之外定义它们。

轴线

在任何一年最多有 14 个奖项并且每个奖项都有一个圆形指示器的情况下,如果需要,可以轻松通过眼睛获得奖项计数。基于此,强调相对奖项分布的指示器(例如显示二战后美国科学奖项的激增)和图表的长长度,我们的图表不需要 y 轴。

对于 x 轴,标注每个十年的开始似乎是合适的。这样可以减少视觉杂乱,并且也是图表历史趋势的标准人类方式。Example 18-2 展示了我们的 x 轴的构建,使用了 D3 的方便的axis对象。我们使用tickValues方法重写刻度值,过滤域范围(1900 年至 2015 年),只返回以零结尾的日期。

示例 18-2。制作 x 轴,每十年一个刻度标签
let xAxis = d3.axisBottom()
    .scale(xScale)
    .tickValues(
      xScale.domain().filter(function (d, i) {
        return !(d % 10) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
      })
    )

1](#co_visualizing_individual_prizes_CO2-1)

我们只想在每十年打勾,所以我们通过过滤 x 轴刻度值并使用它们的索引来选择仅可被 10 整除的值来创建刻度值。这些通过模数(%)10 得到 0,我们将不布尔运算符(!)应用于这些来产生true,这通过了过滤器。

这会对以 0 结尾的年份返回true,从而在每个十年的开始处给出一个刻度标签。

与比例尺一样,我们不预期轴会改变,^(1)所以我们可以在updateTimeChart函数接收数据集之前添加它们:

svg.append("g") // group to hold the axis
    .attr("class", "x axis")
    .attr("transform", "translate(0," + height + ")")
    .call(xAxis) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
    .selectAll("text") ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
    .style("text-anchor", "end")
    .attr("dx", "-.8em")
    .attr("dy", ".15em")
    .attr("transform", "rotate(-65)");

1](#co_visualizing_individual_prizes_CO3-1)

svg组上调用我们的 D3 轴,使用axis对象来构建轴元素。

2](#co_visualizing_individual_prizes_CO3-2)

就像“轴和标签”中一样,我们将轴刻度标签旋转,将它们对角放置。

有了轴和比例尺,我们只需要在移动到图表的酷炫、交互式元素之前添加一点带有彩色类别标签的图例。

类别标签

我们的静态组件中最后一个是图例,包含在图 18-2 中显示的类别标签。

dpj2 1802

图 18-2。类别图例

要创建图例,我们首先创建一个组,类名为labels,用于保存标签。我们将我们的nbviz.CATEGORIES数据绑定到此labels组上的label选择器上,输入绑定的数据,并为每个类别附加一个组,通过索引在 y 轴上偏移:

let catLabels = chartHolder.select('svg').append('g')
        .attr('transform', "translate(10, 10)")
        .attr('class', 'labels')
        .selectAll('label').data(nbviz.CATEGORIES) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
        .join('g')
        .attr('transform', function(d, i) {
            return "translate(0," + i * 10 + ")"; ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
        });

1](#co_visualizing_individual_prizes_CO4-1)

我们将我们的类别数组(["Chemistry", "Economics", …​])与label组结合使用标准的data后跟join方法。

2](#co_visualizing_individual_prizes_CO4-2)

为每个类别创建一个组,垂直间距为 10 像素。

现在我们有了我们的catLabels选择,让我们在每个组中添加一个圆形指示器(与时间线中看到的相匹配)和文本标签:

catLabels.append('circle')
    .attr('fill', (nbviz.categoryFill)) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
    .attr('r', xScale.bandwidth()/2); ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)

catLabels.append('text')
    .text(d => d)
    .attr('dy', '0.4em')
    .attr('x', 10);

1](#co_visualizing_individual_prizes_CO5-1)

我们使用我们共享的categoryFill方法根据绑定的类别返回颜色。

2](#co_visualizing_individual_prizes_CO5-2)

x 轴的bandwidth方法返回两个类别标签之间的距离。我们将使用其中一半来获得我们圆形标签标记的半径。

categoryFill函数(示例 18-3)在nbviz_core.js中定义,并被应用于应用程序以为类别提供颜色。D3 提供了一些颜色方案,以色彩十六进制代码数组的形式提供,这些颜色可以用作我们的 SVG 填充颜色。您可以在Observable上看到颜色方案的演示。由于我们处理的是类别,因此我们将使用 Category10 集。

示例 18-3. 设置类别颜色
// nbviz_core.js nbviz.CATEGORIES = [
    "Physiology or Medicine", "Peace", "Physics",
    "Literature", "Chemistry", "Economics"];

nbviz.categoryFill = function(category){
    var i = nbviz.CATEGORIES.indexOf(category);
    return d3.schemeCategory10[i]; ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
};

1

D3 的schemeCategory10是一个包含 10 个颜色十六进制代码的数组(['#1f77b4', '#ff7f0e', ...]),我们可以根据奖项类别索引应用它们。

现在我们已经涵盖了时间图表的所有静态元素,让我们看看如何使用 D3 的nest库将其转换为可用形式。

将数据进行嵌套

为了创建这个时间线组件,我们需要重新组织我们的扁平化获奖者对象数组,以便将其绑定到我们时间线中的单个诺贝尔奖项。为了尽可能顺利地将这些数据与 D3 绑定,我们需要一个按年份组织的奖项对象数组,其中年份组以数组形式提供。让我们用我们的诺贝尔奖数据集演示转换过程。

以下是我们开始使用的扁平化诺贝尔奖数据集,按年份排序:

[
 {"year":1901,"name":"Wilhelm Conrad R\\u00f6ntgen",...},
 {"year":1901,"name":"Jacobus Henricus van \'t Hoff",...},
 {"year":1901,"name":"Sully Prudhomme",...},
 {"year":1901,"name":"Fr\\u00e9d\\u00e9ric Passy",...},
 {"year":1901,"name":"Henry Dunant",...},
 {"year":1901,"name":"Emil Adolf von Behring",...},
 {"year":1902,"name":"Theodor Mommsen",...},
 {"year":1902,"name":"Hermann Emil Fischer",...},
 ...
];

我们希望将这些数据转换为以下嵌套格式,即年份键和按年份获奖者的对象数组:

[
 {"key":"1901",
  "values":[
   {"year":1901,"name":"Wilhelm Conrad R\\u00f6ntgen",...},
   {"year":1901,"name":"Jacobus Henricus van \'t Hoff",...},
   {"year":1901,"name":"Sully Prudhomme",...},
   {"year":1901,"name":"Fr\\u00e9d\\u00e9ric Passy",...},
   {"year":1901,"name":"Henry Dunant",...},
   {"year":1901,"name":"Emil Adolf von Behring",...}
  ]
 },
 {"key":"1902",
  "values":[
   {"year":1902,"name":"Theodor Mommsen",...},
   {"year":1902,"name":"Hermann Emil Fischer",...},
   ...
  ]
 },
 ...
];

我们可以遍历这个嵌套数组,并依次加入年份组,每个组在我们的时间线中表示为指标的一列。

我们可以利用 D3 的一个group实用方法,按年份对诺贝尔奖进行分组。group接受一个数据数组并根据回调函数中指定的属性返回一个按指定属性分组的对象,对于我们来说是奖项的年份。因此,我们像这样按年份分组我们的条目:

 let nestDataByYear = function (entries) {
    let yearGroups = d3.group(entries, d => d.year) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
	// ...
  }

1

在这里,我们使用了现代 JavaScript 的 lambda 简写函数,相当于function(d) { return d.year }

这个分组将返回以下形式的条目数组:

[ // yearGroups
  {1913: [{year: 1913, ...}, {year: 1913, ...}, ...]},
  {1921: [{year: 1921, ...}, {year: 1921, ...}, ...]},
  ...
]

现在,为了将此映射转换为所需的键-值对象数组,我们将利用 JavaScript 的Array对象及其from方法。我们传入我们的yearGroups映射及一个转换函数,该函数以[key, values]数组的形式接收单独的组,并将它们转换为{key: key, values: values}对象。我们再次使用了解构赋值语法来映射我们的键和值:

let keyValues = Array.from(yearGroups, [key, values] => {key, values})

现在我们已经有了必要的函数,可以按照所需的键-值形式对我们筛选出的获奖条目按年份进行分组:

nbviz.nestDataByYear = function (entries) {
  let yearGroups = d3.group(entries, (d) => d.year);
  let keyValues = Array.from(yearGroups, ([key, values]) => {
    let year = key;
    let prizes = values;
    prizes = prizes.sort(
      (p1, p2) => (p1.category > p2.category ? 1 : -1)); ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
    return { key: year, values: prizes };
  });
  return keyValues;
};

1

我们使用 JavaScript 数组的 sort 方法 按类别按字母顺序对奖项进行排序。这将使年份间的比较更加容易。 sort 期望一个正数或负数的数值,我们通过布尔值字母数字字符串比较生成它。

使用嵌套数据连接添加获奖者

在上一章的“将条形图组合在一起”中,我们看到了 D3 的新 join 方法如何轻松同步数据的变化,即按国家按奖项数量的情况,在可视化方面(在这种情况下是条形图中的条形)。我们按年份分组的获奖者图表本质上是一个条形图,其中各个条形由奖项(圆形标记)表示。现在我们将看到如何通过使用两个数据连接和上一节生成的嵌套数据集轻松实现这一点。

首先将嵌套数据从 onDataChange 传递给我们时间图表的 updateTimeChart 方法。然后,我们使用第一个数据连接创建年份组,并使用我们的序数 x 比例尺将它们定位到像素位置(参见示例 18-1),并按年份命名:

nbviz.updateTimeChart = function (data) {

  let years = svg.selectAll('.year').data(data, d => d.key) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)

  years
    .join('g') ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
    .classed('year', true)
    .attr('name', d => d.key)
    .attr('transform', function (year) {
      return 'translate(' + xScale(+year.key) + ',0)'
    })
    //... }

1

我们要按照年份键(year key)而不是默认的数组索引将年份数据加入到其相应的列中,因为在用户选择的数据集中经常会出现年份间隔,这样默认的数组索引就会改变。

2

我们的第一个数据连接使用键值数组按年份生成圆形条形组。

让我们使用 Chrome 的 Elements 选项卡查看我们从第一次数据连接中做出的更改。图 18-3 显示我们的年份组已嵌套在其父图表组中。

dpj2 1803

图 18-3. 在第一个数据连接期间创建年份组的结果

让我们检查一下我们的嵌套数据是否已正确绑定到其相应的年份组。在图 18-4 中,我们按年份名称选择一个组元素并进行检查。按要求,正确的数据已按年份绑定,显示了 1901 年的六位诺贝尔奖获得者的数据对象数组。

将年份组数据与其相应的组绑定后,我们现在可以将这些值连接到代表每年的圆形标记组。首先,我们需要使用刚刚添加的键值数据选择所有年份组,并将值数组(该年的奖项)绑定到一些获奖者占位符上,这可以通过以下 D3 调用实现:

let winners = svg
      .selectAll('.year')
      .selectAll('circle') ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
      .data(
        d => d.values, ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
        d => d.name    ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
      );

1

我们将为值数组中的每个奖项创建一个圆形标记。

2

我们将使用值数组使用 D3 的连接功能创建圆圈。

3

我们使用一个可选的键来按名称跟踪圆圈/获奖者——这对于过渡效果将会很有用,正如我们稍后将看到的。

dpj2 1804

图 18-4. 使用 Chrome 控制台检查我们第一个数据连接的结果

现在我们已经用获奖条目数据创建了我们的 圆形 占位符,只需使用 D3 的 join 方法将这些占位符连接到一个圆形上。这将跟踪数据的任何更改,并确保圆形在创建和销毁时同步进行。所需的其余令人印象深刻的简洁代码如下所示 示例 18-4。

示例 18-4. 第二个数据连接以生成奖品的圆形指示器
winners
    .join((enter) => {
      return enter.append('circle') ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
               .attr('cy', height)
    })
    .attr('fill', function (d) {
      return nbviz.categoryFill(d.category) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
    })
    .attr('cx', xScale.bandwidth() / 2) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
    .attr('r', xScale.bandwidth() / 2)
    .attr("cy", (d, i) => yScale(i));

1

自定义 enter 方法用于附加任何新需要的圆圈,并为它们设置一个默认的 y 位置在图表底部(SVG 的 y 向下)。

2

一个小帮手方法根据奖项类别返回一个颜色。

3

年度组已经有了正确的 x 位置,所以我们只需要使用条形的带宽来设置半径并将圆心置于条形的中间。y 比例尺用于根据获奖者数组中的索引 i 设置条形的高度。

示例 18-4 中的代码完成了构建我们的奖品时间图表的任务,如有需要,创建新的指示圆并将它们与任何现有的圆一起放置在它们正确的位置,由它们的数组索引指定(见 图 18-5)。

dpj2 1805

图 18-5. 我们成功的第二次数据连接的结果

尽管我们已经制作出了一个完全可用的时间轴,可以响应数据驱动的变化,但过渡有些生硬且缺乏吸引力。^(2) 现在让我们来看看 D3 的强大之处:通过添加两行代码,我们可以为我们的时间轴状态改变带来一个相当酷炫的视觉效果。

一点过渡的闪烁

就目前而言,当用户选择新的数据集时,^(3) 示例 18-4 中的更新模式立即设置相关圆的位置。现在我们要做的是动画化这种重新定位,使其在几秒钟内平稳过渡。

任何用户驱动的过滤都会留下一些现有的指示器(例如,当我们仅从所有类别中选择化学奖项时),添加一些新的指示器(例如,将我们的类别从物理学改变为化学学)或同时进行。一个特殊情况是当过滤器什么都不留下时(例如,选择女性经济学获奖者)。这意味着我们需要决定现有指示器应该做什么,以及如何动画化新指示器的定位。

图 18-6 展示了我们在选择现有数据子集时希望发生的情况,在这种情况下,我们将所有诺贝尔奖限制为仅包括物理学获奖者。在用户选择物理学类别时,所有指示器除物理学指示器外都将通过exitremove方法删除。与此同时,现有的物理学指示器开始从其当前位置开始进行为期两秒的过渡,结束位置由其数组索引决定。

dpj2 1806

图 18-6. 选择现有数据子集时的过渡

您可能会惊讶地发现,通过向现有代码添加仅两行代码,即可实现这两种视觉效果:平滑移动现有条形图到其新位置以及从图表底部增加任何新条形图。这充分展示了 D3 数据连接概念和其成熟设计的强大力量。

为了实现所需的过渡效果,在设置圆形标记的 y 位置(cy属性)之前,我们在调用transitionduration方法。这些方法使圆形的重新定位平滑过渡,在两秒(2000ms)内缓慢进入:

winners
   .join((enter) => {
     return enter.append('circle').attr('cy', height) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
   })
   .attr('fill', function (d) {
     return nbviz.categoryFill(d.category)
   })
   .attr('cx', xScale.bandwidth() / 2)
   .attr('r', xScale.bandwidth() / 2)
   .transition() ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
   .duration(2000)
   .attr("cy", (d, i) => yScale(i));

1

任何新的圆形都从图表底部开始。

2

所有圆形在 2000ms 的持续时间内缓慢进入其 y 位置。

正如你所见,D3 使得在数据过渡中添加酷炫的视觉效果变得非常简单。这证明了其坚实的理论基础。我们现在拥有完整的时间线图表,它能够平滑过渡以响应用户启动的数据更改。

更新条形图

当时间图表模块被导入时,它会将一个回调函数附加到核心模块中的回调数组中。当用户交互导致数据更新时,此回调函数被调用,并使用新的按年嵌套的国家数据更新时间图表:

nbviz.callbacks.push(() => { ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
  let data = nbviz.nestDataByYear(nbviz.countryDim\
              .top(Infinity))
  updateTimeChart(data)
})

1

此匿名函数在核心模块中在数据更新时调用。

总结

继续第十七章中的条形图后,本章扩展了更新模式,展示了如何使用嵌套数据的第二次数据连接创建新的图表。重要的是强调,创建新颖可视化的能力是 D3 的巨大优势:您不仅限于传统图表库的特定功能,而可以实现数据的独特转换。正如我们的诺贝尔奖条形图所示,构建传统的动态图表非常简单,但 D3 允许更多。

我们还看到,一旦建立了稳定的更新模式,如何通过引人入胜的变换轻松激活您的可视化。

在下一章中,我们将使用 D3 强大的地形库构建我们的 Nobel-viz 的地图组件。

^(1) D3 提供了一些方便的画笔工具,可以轻松选择 x 轴或 y 轴的部分。结合过渡效果,这可以创建出一个引人入胜且直观的方式,提高大型数据集的分辨率。参见这个bl.ocks.org页面以获取一个很好的例子。

^(2) 正如在“过渡”中讨论的那样,从一个数据集到另一个的视觉过渡既具信息性又能增加可视化的连贯感,使其更具吸引力。

^(3) 例如,通过类别筛选奖项,只展示物理学奖获得者。

第十九章:使用 D3 进行映射

构建和自定义地图可视化是 D3 的核心优势之一。它有一些非常复杂的库,允许使用各种投影,从工作马卡托和正投影到更奇特的投影,如等距圆锥投影。地图似乎是 Mike Bostock 和 Jason Davies,D3 的核心开发人员的一种痴迷,并且他们对细节的关注令人印象深刻。如果您有地图问题,D3 很可能可以完成所需的繁重工作。^(1) 在本章中,我们将使用我们的诺贝尔奖可视化(Nobel-viz)地图(图 19-1)来介绍 D3 映射的核心概念。

dpj2 1503

图 19-1。本章的目标元素

可用地图

最流行的映射格式是老化的shapefile,这是为地理信息系统(GIS)软件开发的。有许多免费和专有的桌面程序^(2) 用于操作和生成 shapefiles。

不幸的是,shapefiles 不是为网络设计的,网络更希望处理基于 JSON 的地图格式,并要求使用小而高效的表示形式来限制带宽和相关的延迟。

好消息是,有许多方便的方法可以将 shapefiles 转换为我们首选的 TopoJSON 格式,^(3) 这意味着您可以在软件中操作您的 shapefiles,然后将它们转换为网络友好的格式。寻找网络数据可视化地图的标准方法是首先寻找 TopoJSON 或 GeoJSON 版本,然后在更丰富的 shapefiles 池中搜索,作为最后的手段,使用 shapefile 或等价编辑器自行制作。根据您打算进行的地图可视化程度,可能会有现成的解决方案。对于像世界地图或大陆投影(例如流行的 Albers USA)这样的东西,通常可以找到不同精度的多个解决方案。

对于我们的诺贝尔奖地图,我们想要一个全球映射,至少显示所有 58 个诺贝尔奖获奖国家,并为其中的大多数国家提供标记形状。幸运的是,D3 提供了许多示例世界地图,一个以 50 米网格分辨率,另一个以较小的 110 米分辨率。对于我们相当粗糙的需求,后者就足够了。^(4)

D3 的映射数据格式

D3 利用两种基于 JSON 的几何数据格式,GeoJSONTopoJSON,后者是由 Mike Bostock 设计的 GeoJSON 的扩展,用于编码拓扑信息。GeoJSON 更直观易读,但在大多数情况下,TopoJSON 更高效。通常,地图会被转换为 TopoJSON 进行网络传输,因为尺寸是一个重要考虑因素。然后,在浏览器中通过 D3 将 TopoJSON 转换为 GeoJSON,以简化 SVG 路径创建、功能优化等操作。

注意

Stack Overflow上有一篇关于 TopoJSON 和 GeoJSON 之间差异的很好的总结。

现在让我们来看看这两种格式。理解它们的基本结构很重要,在您的映射工作变得更加雄心勃勃时,稍加努力将会有所回报。

GeoJSON

GeoJSON 文件包含一个type对象,其中一个是 Point,MultiPoint,LineString,MultiLineString,Polygon,MultiPolygon,GeometryCollection,Feature 或 FeatureCollection。类型成员的大小写必须是CamelCase,如此所示。它们还可以包含一个crs成员,指定特定的坐标参考系统。

FeatureCollections 是最大的 GeoJSON 容器,通常用于指定包含多个区域的地图。FeatureCollections 包含一个features数组,其中每个元素是前文列出类型的 GeoJSON 对象。

示例 19-1 展示了一个包含国家地图数组的典型 FeatureCollection,其边界由多边形指定。

示例 19-1. GeoJSON 映射数据格式
{
  "type": "FeatureCollection", ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
  "features":  ![2    {      "type": "Feature",      "id": "AFG",      "properties": {        "name": "Afghanistan"      },      "geometry": { ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
        "type": "Polygon",
        "coordinates": 
          [
            [
              61.210817, ![4
              35.650072
            ],
            [
              62.230651,
              35.270664
            ],
            ...
          ]
        ]
      }
    },
    ...
    {
      "type": "Feature",
      "id": "ZWE",
      "properties": {
        "name": "Zimbabwe"
      },
      "geometry": {
        "type": "Polygon",
        "coordinates": [
          [
            [...] ] ]
      }
    }
  ]
}

1

每个 GeoJSON 文件包含一个带有类型的单个对象,包含…​

2

…​一个特征数组—​在本例中,国家对象…​

3

…​使用基于坐标的多边形几何图形。

4

请注意,地理坐标以[经度,纬度]对给出,这与传统的地理定位相反。这是因为 GeoJSON 使用[X,Y]坐标方案。

虽然 GeoJSON 比 shapefile 更简洁且使用首选的 JSON 格式,但在地图编码中存在大量冗余。例如,共享边界被指定两次,而浮点坐标格式相当不灵活,并且对于许多工作来说太精确了。TopoJSON 格式旨在解决这些问题,并以更有效的方式向浏览器提供地图。

TopoJSON

由 Mike Bostock 开发的 TopoJSON 是 GeoJSON 的扩展,它编码了拓扑关系,从称为弧线段的共享池中将几何图形拼接在一起。因为它们重用这些弧线段,TopoJSON 文件通常比其等效的 GeoJSON 文件小 80%!此外,采用拓扑方法来表示地图使得可以使用一些利用拓扑的技术。其中之一是保持拓扑的形状简化,^(5)可以消除 95%的地图点数,同时保留足够的细节。还能实现等面积图和自动地图着色。示例 19-2 展示了 TopoJSON 文件的结构。

示例 19-2. 我们的 TopoJSON 世界地图的结构
{
   "type": "Topology",   ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
   "objects":{           ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
      "countries":{
        "type": "GeometryCollection",
        "geometries": [{
        "_id":24, "arcs":[[6,7,8],[10,11,12]], ... ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
      ...}]},
      "land":{...},
   },
   "arcs":[[[67002,72360],[284,-219],[209..]], /*<-- arc*/ number 0 ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/4.png)
           [[70827,73379],[50,-165]], ...      /*<-- arc number 1*/
        ]
   "transform":{        ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/5.png)
      "scale":[
         0.003600036...,
         0.001736468...,
       ],
       "translate":[
          -180,
          -90
       ]
   }
}

1

TopoJSON 对象有一个Topology类型,并且必须包含一个objects对象和一个arcs数组。

2

在这种情况下,对象是countriesland,都是由弧线定义的GeometryCollections

3

每个几何体(在这种情况下定义为国家形状)由一些弧线路径定义,这些路径由它们在arcs数组中的索引引用 4

4

用于构建对象的组件弧线数组。这些弧线由索引引用。

5

以整数形式量化位置所需的数字。

如果您需要将其传输到 Web 浏览器中,TopoJSON 格式的较小尺寸显然是一个巨大优势。通常只有 GeoJSON 格式是可用的,因此能够将其转换为 TopoJSON 是非常方便的。D3 提供了一个小的命令行实用程序来完成这个任务。名为geo2topo,它是 TopoJSON 包的一部分,并且可以通过node进行安装。

将地图转换为 TopoJSON

您可以通过node存储库安装 TopoJSON(参见第一章),使用-g标志进行全局安装^(6):

$ npm install -g topojson

安装了topojson后,将现有的 GeoJSON 转换为 TopoJSON 就变得非常简单。在这里,我们从命令行调用geo2topo,对一个名为geo_input.json的 GeoJSON 文件进行处理,并指定输出文件topo_output.json

$ geo2topo -o topo_output.json geo_input.json

或者,您可以将结果导入到一个文件中:

$ geo2topo geo_input.json > topo_output.json

geo2topo有许多有用的选项,例如量化,允许您指定地图的精度。尝试使用这个选项可以生成一个文件更小、质量几乎无损的结果。您可以在geo2topo 命令行参考中查看完整的规格说明。如果您希望以编程方式转换地图文件,可以使用一个方便的 Python 库topojson.py。您可以在GitHub上找到它。

现在我们已经将地图数据转换为轻量级、高效且面向 Web 优化的格式,让我们看看如何使用 JavaScript 将其转换为交互式 Web 地图。

D3 Geo,投影和路径

D3 拥有一个客户端的topojson库,专门用于处理 TopoJSON 数据。它将优化的基于弧线的 TopoJSON 转换为基于坐标的 GeoJSON,可以被 D3 的d3.geo库的projections 和paths对象操作。

示例 19-3 展示了从 TopoJSON world-100m.json地图中提取我们的诺贝尔地图所需的 GeoJSON 要素的过程。这为我们提供了代表国家及其边界的基于坐标的多边形。

为了从刚刚传递到浏览器的 TopoJSON world 对象中提取我们需要的 GeoJSON 特征,我们使用 topojsonfeaturemesh 方法。feature 返回指定对象的 GeoJSON 特征或 FeatureCollection,而 mesh 返回表示指定对象网格的 GeoJSON MutliLineString 几何对象。

featuremesh 方法的第一个参数是 TopoJSON 对象,第二个参数是我们想要提取的特征的引用(在 示例 19-3 中为 landcountries)。在我们的世界地图中,countries 是一个具有国家数组特征的 FeatureCollection(示例 19-3,2)。

mesh 方法有第三个参数,用于指定一个过滤函数,该函数接受两个几何对象(ab)作为参数,这两个对象共享网格弧。如果弧未共享,则 ab 相同,允许我们在我们的世界地图中过滤掉外部边界(示例 19-3,3)。

示例 19-3. 提取我们的 TopoJSON 特征
// nbviz_main.mjs import { initMap } from './nbviz_map.mjs'

Promise.all(
   d3.json('static/data/world-110m.json'), ![1
   d3.csv('static/data/world-country-names-nobel.csv'),
   // ...
 ]).then(ready)

function ready([worldMap, countryNames, countryData, winnersData]) {
    // ...
    nbviz.initMap(worldMap, countryNames)
}
// nbviz_map.mjs export let initMap = function(world, names) {
    // EXTRACT OUR REQUIRED FEATURES FROM THE TOPOJSON
    let land = topojson.feature(world, world.objects.land),
        countries = topojson.feature(world, world.objects.countries)
                      .features, ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
        borders = topojson.mesh(world, world.objects.countries,
                    function(a, b) { return a !== b; }); ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
    // ... }

1

使用 D3 的辅助函数加载地图数据,并将其发送到 ready 函数以初始化地图图表。

2

使用 topojson 从 TopoJSON 数据中提取我们需要的特征,并以 GeoJSON 格式提供它们。

3

仅过滤出仅在国家之间共享的内部边界。如果一条弧仅被一个几何体(在本例中是一个国家)使用,则 ab 是相同的。

在 D3 中的地图展示通常遵循一个标准模式。首先,我们创建一个 D3 projection,使用其中一个 D3 的多种选择。然后,我们使用这个 projection 创建一个 path。这个 path 用于将从我们的 TopoJSON 对象中提取的特征和网格转换为浏览器窗口中显示的 SVG 路径。现在让我们来看看关于 D3 projections 的丰富主题。

投影

自从人们意识到地球是一个球体以来,地图的主要挑战可能是在二维形式中表示一个三维的球体或其重要部分。1569 年,弗兰德斯地图制作者杰拉德斯·麦卡托(Gerardus Mercator)通过从地球中心向显著的边界坐标延伸线,并将其投影到周围的圆柱体上,成功地解决了这个问题。这个投影方法的有用属性是将常数航线(称为 rhumb lines)表示为直线段,这对打算使用地图的航海导航员非常有用。不幸的是,投影过程会扭曲距离和大小,随着从赤道到极地的移动,会放大尺度。因此,尽管实际上非洲大陆的面积大约是格陵兰的 14 倍,但在地图上,它看起来并不比格陵兰大多少。

所有的投影方式都像墨卡托的一样是一种妥协,而 D3 的优点在于丰富的选择意味着可以平衡这些妥协,找到适合工作的正确投影方式。^(7) 图 19-2 展示了我们诺贝尔地图的一些替代 projection,包括最终可视化所选择的等经纬度投影。约束条件是在矩形窗口内显示所有诺贝尔奖获得国家,并尝试最大化空间,特别是在欧洲,那里有许多地理上较小但奖项相对较多的国家。

要创建一个 D3 的 projection,只需使用适用的 d3.geo 方法之一:

let projection = d3.geoEquirectangular()
// ...

D3 的 projection 具有许多有用的方法。通常使用 translate 方法将地图平移至容器的一半宽度和高度,覆盖默认值 [480, 250]。还可以设置精度,这会影响在 projection 中使用的自适应重采样的程度。自适应重采样是一种聪明的技术,可以提高投影线的准确性,同时保持高效率。^(8) 地图的比例及其中心的经度和纬度可以通过 scalecenter 方法进行设置。

dpj2 1902

图 19-2. 诺贝尔地图的一些替代映射投影方式

projection 方法组合在一起,下面的代码是我们的诺贝尔可视化世界等经纬度地图所使用的代码。请注意,它是手动调整的,以最大化给予诺贝尔奖获得国家的空间。两极被截断,因为在北极或南极都没有获奖者(请注意,等经纬度地图假设宽高比为 2):

let projection = d3.geoEquirectangular()
    .scale(193 * (height/480)) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
    .center([15,15]) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
    .translate([width / 2, height / 2])
    .precision(.1);

1

稍微放大;默认高度为 480,比例为 153。

2

居中于东经 15 度,北纬 15 度。

定义了我们的等经纬度 projection 后,让我们看看如何使用它创建 path,这将用于创建 SVG 地图。

路径

一旦确定了适合您地图的合适 projection,您可以使用它来创建 D3 地理 path 生成器,这是 SVG path 生成器 (d3.svg.path) 的专门变体。此 path 接受任何 GeoJSON 特征或几何对象,如 FeatureCollection、Polygon 或 Point,并返回用于 d 元素的 SVG 路径数据字符串。例如,使用我们的地图 borders 对象,描述 MultiLineString 的地理边界坐标将转换为 SVG 的路径坐标。

通常,我们一次性创建我们的 path 并设置其 projection

var projection = d3.geoEquirectangular()
// ...

var path = d3.geoPath()
             .projection(projection);

通常,我们使用path作为函数来生成 SVG 路径的d属性,使用datum方法绑定使用的 GeoJSON 数据(用于绑定单个对象——而不是数组——并且data([object])的简写)。因此,要使用刚刚提取的边界数据使用topojson.mesh来绘制我们的国家边界,我们使用以下方法:

// BOUNDRY MARKS svg.insert("path", ".graticule") ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
    .datum(borders)
    .attr("class", "boundary")
    .attr("d", path);

1

我们希望在地图的graticule(网格)覆盖层之前(下面)插入边界 SVG。

图 19-3 展示了从 Chrome 控制台输出的 TopoJSON borders对象,从我们的世界地图数据中提取的结果路径。使用了我们的d3.geo path,使用了等经纬投影。

地理路径生成器是 D3 地图演示的主要组成部分。我建议尝试使用不同的projection和简单几何体来感受一下,可以在bl.ocks.org找到大量的示例,以及D3 的 GitHub 页面上的文档,并查看这个很棒的小演示

dpj2 1903

图 19-3. 路径生成器,从几何到 SVG 路径

现在让我们来看看你在地图中将使用的一个有用的d3.geo组件,即graticule(或地图网格)。

经线网格

d3.geo中一个有用的组件,也是我们诺贝尔地图中使用的graticule,是地理形状生成器之一。⁹ 它创建了经线(经度线)和纬线(纬度线)的全局网格,默认间距为 10 度。当我们将path应用于这个graticule时,它生成了一个适当投影的网格,如图 19-1 所示。

示例 19-4 展示了如何向您的地图添加一个graticule。请注意,如果您希望网格覆盖地图路径,则其 SVG 路径应该在 DOM 树中地图路径之后。正如您将看到的,您可以使用 D3 的insert方法来强制执行此顺序。

示例 19-4. 创建一个graticule
var graticule = d3.geo.graticule()
                  .step([20, 20]); ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)

svg.append("path")
    .datum(graticule) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
    .attr("class", "graticule")
    .attr("d", path); ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)

1

创建一个graticule,将网格间距设置为 20 度。

2

注意datum是用于数据的简写([graticule])。

3

使用path生成器接收graticule数据并返回网格路径。

现在我们已经有了我们的网格覆盖层,以及将地图文件转换为具有所需projection的 SVG 路径的能力,让我们将元素放在一起。

将元素放在一起

使用讨论的projectionpathgraticule组件,我们将创建基本地图。此地图旨在响应用户事件,突出显示由选定获奖者代表的国家,并在国家中心用填充的红色圆圈反映获奖者数量。我们将单独处理此互动更新。

示例 19-5 显示了构建基本全球地图所需的代码。它遵循了现在应该是熟悉的模式,从其 div 容器(ID nobel-map)获取 mapContainer,向其附加一个 <svg> 标签,然后继续添加 SVG 元素,这些元素在这种情况下是由 D3 生成的地图路径。

我们的地图有一些固定的组件(例如 projectionpath 的选择),它们不依赖于任何数据变化,并且在初始化 nbviz.initMap 方法之外定义。当从服务器初始化可视化时,将调用 nbviz.initMap。它接收 TopoJSON world 对象,并使用它构建带有 path 对象的基本地图。图 19-4 显示了结果。

示例 19-5。构建地图基础
// DIMENSIONS AND SVG let mapContainer = d3.select('#nobel-map');
let boundingRect = mapContainer.node().getBoundingClientRect();
let width = boundingRect.width
    height = boundingRect.height;
let svg = mapContainer.append('svg');
// OUR CHOSEN PROJECTION let projection = d3.geo.equirectangular()
    .scale(193 * (height/480))
    .center([15,15])
    .translate([width / 2, height / 2])
    .precision(.1);
// CREATE PATH WITH PROJECTION let path = d3.geoPath().projection(projection);
// ADD GRATICULE var graticule = d3.geoGraticule().step([20, 20]);
svg.append("path").datum(graticule)
    .attr("class", "graticule")
    .attr("d", path);
// A RADIUS SCALE FOR OUR CENTROID INDICATORS var radiusScale = d3.scaleSqrt()
    .range([nbviz.MIN_CENTROID_RADIUS, nbviz.MAX_CENTROID_RADIUS]);
// OBJECT TO MAP COUNTRY NAME TO GEOJSON OBJECT var cnameToCountry = {};
// INITIAL MAP CREATION, USING DOWNLOADED MAP DATA export let initMap = function(world, names) { ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
    // EXTRACT OUR REQUIRED FEATURES FROM THE TOPOJSON
    var land = topojson.feature(world, world.objects.land),
        countries = topojson.feature(world, world.objects.countries)
                      .features,
        borders = topojson.mesh(world, world.objects.countries,
                    function(a, b) { return a !== b; });
    // CREATE OBJECT MAPPING COUNTRY NAMES TO GEOJSON SHAPES
    var idToCountry = {};
    countries.forEach(function(c) {
        idToCountry[c.id] = c;
    });

    names.forEach(function(n) {
        cnameToCountry[n.name] = idToCountry[n.id]; ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
    });
    // MAIN WORLD MAP
    svg.insert("path", ".graticule") ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
        .datum(land)                 ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/4.png)
        .attr("class", "land")
        .attr("d", path)
    ;
    // COUNTRY PATHS
    svg.insert("g", ".graticule")
        .attr("class", 'countries');
    // COUNTRIES VALUE-INDICATORS
    svg.insert("g")
        .attr("class", "centroids");
    // BOUNDARY LINES
    svg.insert("path", ".graticule")
        .datum(borders)
        .attr("class", "boundary")
        .attr("d", path);

};

1

world 是包含国家特征及 names 数组的 TopoJSON 对象,将国家名称与国家特征 ID 相连接(例如,{id:36, name: 'Australia'})。

2

给定国家名称键,返回其相应 GeoJSON 几何体的对象。

3

请注意,我们在 graticule 网格之前插入了此 path,将网格覆盖在顶部。

4

使用 datum 将整个 land 对象分配给我们的 path

dpj2 1904

图 19-4。基本地图

地图形状已经就位,我们可以使用一些 CSS 样式化图 19-4,为海洋添加浅蓝色,为陆地添加浅灰色。graticule 是半透明的深灰色,国家边界是白色:

/* NOBEL-MAP STYLES */
#nobel-map {
    background: azure;
}

.graticule {
    fill: none;
    stroke: #777;
    stroke-width: .5px;
    stroke-opacity: .5;
}

.land {
    fill: #ddd;
}

.boundary {
    fill: none;
    stroke: #fff;
    stroke-width: .5px;
}

组装了 SVG 地图之后,让我们看看如何使用获奖者数据集来绘制诺贝尔奖获得国家以及获奖次数的红色指示器。

更新地图

当我们初始化可视化时,我们的诺贝尔奖得主地图首次更新。此时,所选数据集未经过过滤,包含所有的诺贝尔奖获得者。随后,根据用户应用的过滤器(例如,所有化学奖获得者或来自法国的获奖者),数据集将发生变化,我们的地图也会相应变化以反映这一点。

因此,更新地图涉及将当前奖项获取情况的诺贝尔奖获得国家数据集发送给地图,这取决于用户应用的过滤器。为此,我们使用一个 updateMap 方法:

let updateMap = function(countryData) { //...
                }

countryData 数组的形式如下:


  {
   code: "USA",
   key: "United States",
   population: 319259000,
   value: 336 ![1
  },
  // ... 56 more countries ]

1

当前所选数据集中美国的获奖者数量。

我们希望在将此数组发送到我们的 D3 地图之前将其转换。以下代码完成了这项工作,提供了一个带有 geo 属性(国家的 GeoJSON 几何体)、name 属性(国家名称)和 number 属性(诺贝尔奖获得者数量)的国家对象数组:

let mapData = countryData
    .filter(d => d.value > 0) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
    .map(function(d) {
      return {
        geo: cnameToCountry[d.key], ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
        name: d.key,
        number: d.value
      }
    });

1

过滤掉没有获奖者的国家——我们只在地图上显示获奖国家。

2

使用国家的关键字(在本例中为其名称)来检索其 GeoJSON 特征。

我们希望在获奖国家的中心显示一个红色的圆形指示器,指示获奖数量。这些圆的面积应与获奖数量(绝对值或人均)成比例,这意味着(通过圆面积= pi × 半径平方)它们的半径应该是该奖品数量的平方根的函数。D3 提供了一个方便的sqrt比例尺,用于这种需要,允许您设置一个域(在本例中为最小和最大奖品数量)和一个范围(最小和最大指示器半径)。

让我们快速看看sqrt比例尺的示例。在以下代码中,我们设置一个域在 0 到 100 之间的比例尺,并且以基于零的范围设置了最大面积为 25(5 × 5)。这意味着调用该比例尺的 50(范围的一半)应该给出一半最大面积的平方根(12.5):

var sc = d3.scaleSqrt().domain([0, 100]).range([0, 5]);
sc(50) // returns 3.5353..., the square root of 12.5

为了创建我们的指标半径比例尺,我们使用nbviz_core.js中指定的最大和最小半径来设置其范围的sqrt比例尺:

var radiusScale = d3.scaleSqrt()
    .range([nbviz.MIN_CENTROID_RADIUS,
    nbviz.MAX_CENTROID_RADIUS]);

为了将我们的范围设置为尺度的域,我们使用这个mapData来获取每个国家获奖者的最大数量,并将该值作为域的上限值,域的下限为0

var maxWinners = d3.max(mapData.map(d => d.number))
// DOMAIN OF VALUE-INDICATOR SCALE
radiusScale.domain([0, maxWinners]);

要将我们国家的形状添加到现有地图中,我们将mapData绑定到countries组的country类的选择上,并实现更新模式(见“使用数据更新 DOM”),首先添加mapData所需的任何国家形状。我们使用 CSS 的opacity属性,而不是移除未绑定的国家路径,使绑定的国家可见,未绑定的不可见。使用两秒的过渡效果使这些国家适当地淡入和淡出。示例 19-6 展示了更新模式。

示例 19-6。更新国家形状
let countries = svg
   .select('.countries').selectAll('.country')
   .data(mapData, d => d.name)
// Use a data-join to make selected countries visible // and fade them in over TRANS_DURATION milliseconds
 countries
   .join(
     (enter) => {
       return enter
         .append('path') ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
		 .attr('d', function (d) {
		     return path(d.geo)
         })
         .attr('class', 'country')
         .attr('name', d => d.name)
         .on('mouseenter', function (event, d) { ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
           d3.select(this).classed('active', true)
         })
         .on('mouseout', function (d) {
           d3.select(this).classed('active', false)
         })
     },
     (update) => update,
     (exit) => { ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
       return exit
         .classed('visible', false)
         .transition()
         .duration(nbviz.TRANS_DURATION)
         .style('opacity', 0)
     }
   )
   .classed('visible', true)
   .transition() ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/4.png)
   .duration(nbviz.TRANS_DURATION)
   .style('opacity', 1)

1

使用 GeoJSON 数据使用我们的path对象创建国家地图形状。

2

UI 占位符,将 SVG 路径设置为鼠标悬停时的类active。请注意,我们在这里使用function关键字,而不是通常的箭头符号缩写()。这是因为我们希望使用 D3 访问由鼠标输入的 DOM 元素(地图区域),并使用this关键字,而箭头函数不支持该关键字。

3

自定义的exit函数,以 2000 毫秒的速度淡出(将透明度设置为 0)国家形状。

4

任何新国家都会在 2000 毫秒内逐渐淡入(透明度为 1)。

请注意,我们向新输入的国家添加了 CSS 类country,将它们的颜色设置为浅绿色。除此之外,鼠标事件用于将国家分类为active,如果光标悬停在其上,则用较深的绿色突出显示。这是 CSS 类:

.country{
    fill: rgb(175, 195, 186); /* light green */
}

.country.active{
    fill: rgb(155, 175, 166); /* dark green */
}

如示例 19-6 所示的更新模式将平滑过渡从旧数据集到新数据集,这是响应用户应用的过滤器并传递给 updateMap 的结果。现在我们只需要添加类似响应的填充圆形指示器,这些指示器位于活跃国家的中心,并反映其当前值,无论是诺贝尔奖的绝对还是相对(人均)测量。

添加价值指示器

要添加我们的圆形值指示器,我们需要一个更新模式,它与用于创建国家 SVG 路径的模式相似。我们希望绑定到 mapData 数据集并相应地追加、更新和删除我们的指示器圆圈。与国家形状一样,我们将调整指示器的不透明度以添加和移除它们。

指示器需要放置在各自国家的中心位置。D3 的 path 生成器提供了许多处理 GeoJSON 几何体的实用工具方法之一就是 centroid,它计算指定要素的投影质心:

// Given the GeoJSON of country (country.geo)
// calculate x, y coords of center
var center = path.centroid(country.geo);
// center = [x, y]

虽然 path.centroid 通常能很好地工作,并且对于标记形状、边界等非常有用,但它在高度凹凸不平的几何体上可能会产生奇怪的结果。我们在“为诺贝尔数据可视化获取国家数据”中存储的世界国家数据恰好包含所有诺贝尔奖获得国的中心坐标。

我们将首先编写一个小方法来检索那些给定 mapData 对象的方法:

var getCentroid = function(d) {
    var latlng = nbviz.data.countryData[d.name].latlng; ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
    return projection([latlng[1], latlng[0]]); ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
};

1

使用存储的世界国家数据,按名称获取我们国家中心的纬度和经度。

2

利用我们的等经纬投影将其转换为 SVG 坐标。

如示例 19-7 所示,我们将 mapData 绑定到我们在示例 19-5 中添加的 centroids 组中所有 centroid 类元素的选择器上。数据通过 name 键进行绑定。

示例 19-7. 给诺贝尔国家的质心添加奖项指示器
let updateMap = function(countryData) {
//...
  // BIND MAP DATA WITH NAME KEY
  let centroids = svg
     .select('.centroids').selectAll('.centroid')
     .data(mapData, d => d.name) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
  // JOIN DATA TO CIRCLE INDICATORS
  centroids
    .join(
      (enter) => {
        return enter
          .append("circle")
          .attr("class", "centroid")
          .attr("name", (d) => d.name)
          .attr("cx", (d) => getCentroid(d)[0]) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
          .attr("cy", (d) => getCentroid(d)[1])
      },
      (update) => update,
      (exit) => exit.style("opacity", 0)
    )
    .classed("active",
      (d) => d.name === nbviz.activeCountry)
    .transition()
    .duration(nbviz.TRANS_DURATION) ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
    .style("opacity", 1)
    .attr("r", (d) => radiusScale(+d.number))
};

1

使用 name 键将地图数据绑定到质心元素。

2

使用 getCentroid 函数返回国家中心的地理坐标的像素位置。

3

此 2000 毫秒的过渡通过增加其不透明度逐渐淡入圆形标记,同时过渡到其新半径。

利用一些 CSS,我们可以将指示器设为红色并略显透明,允许地图细节和(在欧洲密集区域)其他指示器显示出来。如果用户选择了国家,可以使用 UI 栏上的国家过滤器,这些国家将被标记为 active 并呈现金色。以下是实现此效果的 CSS 代码:

.centroid{
    fill: red;
    fill-opacity: 0.3;
    pointer-events: none; ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
}

.centroid.active {
    fill: goldenrod;
    fill-opacity: 0.6;
}

1

这样可以让鼠标事件传播到圆形下面的国家形状,使用户仍然可以点击它们。

我们刚刚添加的活动重心指示器是我们诺贝尔奖地图的最后一个元素。现在让我们来看一下完整的文章。

我们完成的地图

完成国家和指示器更新模式后,我们的地图应能够对用户驱动的过滤作出平滑的过渡。图 19-5 显示了选择经济学诺贝尔奖的结果。只有获奖国家保持突出显示,并且值指示器被调整大小,反映了美国在该类别中的主导地位。

dpj2 1905

图 19-5. (左)显示带有完整诺贝尔数据集的地图;(右)奖项按类别进行过滤,显示经济学获奖者(以及美国经济学家的主导地位)

当用户将鼠标移到国家上时,调用mouseentermouseout回调函数并添加或删除active类时,在不互动的地图上显示。这些回调函数可以很容易地用于增加地图的更多功能,例如工具提示或使用国家作为可点击的数据过滤器。现在让我们使用它们来构建一个简单的工具提示,显示鼠标悬停在哪个国家和一些简单的奖励信息。

构建一个简单的工具提示

工具提示和其他交互式小部件通常是数据可视化者常常要求的内容,尽管它们可能变得非常复杂,特别是如果它们本身是交互式的(例如,鼠标悬停时出现的菜单),但有一些简单的技巧非常有用。在本节中,我将展示如何构建一个简单但相当有效的工具提示。图 19-6 展示了我们的目标。

dpj2 1504

图 19-6. 我们诺贝尔奖地图的简单工具提示

让我们回顾一下我们当前的countries更新,其中在数据连接过程中添加了mouseentermouseout事件处理程序:

    // ENTER AND APPEND ANY NEW COUNTRIES
    countries.join((enter) => {
      return enter.append('path')
      // ...
        .on('mouseenter', function(d) {
            d3.select(this).classed('active', true);
        })
        .on('mouseout', function(d) {
            d3.select(this).classed('active', false);
        })
      })
    ;

为了向我们的地图添加工具提示,我们需要做三件事:

  1. 在 HTML 中创建一个带有所需信息占位符的工具提示框,例如,所选奖项类别中的国家名称和获奖数量。

  2. 当用户将鼠标移入一个国家时,在鼠标移出时隐藏 HTML 框。

  3. 使用绑定到鼠标下方国家的数据更新显示框。

我们通过在 Nobel-viz 地图部分添加一个内容块来创建工具提示的 HTML,其 ID 为map-tooltip,一个<h2>标题和一个用于工具提示文本的<p>标签:

<!-- index.xhtml  -->
      <!-- ...  -->
        <div id="nobel-map">
          <div id="map-tooltip">
            <h2></h2>
            <p></p>
          </div>
      <!-- ...  -->

我们还需要一些 CSS 来调整工具提示的外观和感觉,添加到我们的style.css文件中:

/* css/style.css */
/* MAP TOOLTIP */
#map-tooltip {
    position: absolute;
    pointer-events: none; ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
    color: #eee;
    font-size: 12px;
    opacity: 0.7; /* a little transparent */
    background: #222;
    border: 2px solid #555;
    border-color: goldenrod;
    padding: 10px;
    left: -999px; ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
}

#map-tooltip h2 {
    text-align: center;
    padding: 0px;
    margin: 0px;
}

1

pointer-events设置为none有效地允许您在工具提示下面点击东西。

2

最初,提示框隐藏在浏览器窗口的(虚拟)左侧,使用一个较大的负 x 索引。

我们的提示框 HTML 已经就位,并且元素隐藏在浏览器窗口的左侧(left 是 -9999 像素),我们只需扩展我们的 mouseinmouseout 回调函数来显示或隐藏提示框。mousein 函数在用户将鼠标移动到国家时被调用,完成大部分工作:

// ... countries.join(
    (enter) => {
    .append('path')
    .attr('class', 'country')
    .on('mouseenter', function(event) {

        var country = d3.select(this);
        // don't do anything if the country is not visible
        if(!country.classed('visible')){ return; }

        // get the country data object
        var cData = country.datum();
        // if only one prize, use singular 'prize'
        var prize_string = (cData.number === 1)?
            ' prize in ': ' prizes in ';
        // set the header and text of the tooltip
        tooltip.select('h2').text(cData.name);
        tooltip.select('p').text(cData.number
            + prize_string + nbviz.activeCategory);
        // set the border color according to selected
        // prize category
        var borderColor =
          (nbviz.activeCategory === nbviz.ALL_CATS)?
            'goldenrod':
            nbviz.categoryFill(nbviz.activeCategory);
        tooltip.style('border-color', borderColor);

        var mouseCoords = d3.pointer(event); ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
        var w = parseInt(tooltip.style('width')), ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
            h = parseInt(tooltip.style('height'));
        tooltip.style('top', (mouseCoords[1] - h) + 'px'); ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
        tooltip.style('left', (mouseCoords[0] - w/2) + 'px');

        d3.select(this).classed('active', true);
    })
    .on('mouseout', function (d) {
      tooltip.style('left', '-9999px') ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/4.png)
      d3.select(this).classed('active', false)
    })
  }, // ... )

1

D3 的 pointer 方法从 event 对象中返回鼠标坐标(这里是相对于父地图组的),以像素为单位,我们可以用它来定位提示框。

2

我们获取了提示框框的计算宽度和高度,已调整以适应我们的国家标题和奖项字符串。

3

我们使用鼠标坐标和提示框框的宽度和高度来将框居中水平放置,并大致位于鼠标光标上方(宽度和高度不包括我们的 10 像素填充周围的提示框 <div>)。

4

当鼠标离开一个国家时,我们通过将其放置到地图的最左边使提示框消失。

编写了 mouseenter 回调函数后,我们现在只需要一个 mouseout 来通过将其放置到浏览器窗口的左侧远处来隐藏提示框:

countries.join(
    (enter) => {
    .append('path')
    .attr('class', 'country')
    .on('mouseenter', function(event) {
    // ...
    })
    .on('mouseout', function (d) {
      tooltip.style('left', '-9999px') ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
      d3.select(this).classed('active', false)
    })
  }, // ... )

1

当鼠标离开国家时,我们将提示框移到浏览器视口的左侧远处,并从国家中移除 'active' 类,使其返回默认国家颜色。

随着 mouseentermouseout 函数协同工作,你应该可以看到提示框根据需要出现和消失,正如 Figure 19-6 所示。

更新地图

当地图模块被导入时,它会将回调函数附加到核心模块中的回调数组中。当用户交互响应中更新数据时,将调用此回调函数,并使用新的国家数据更新条形图:

nbviz.callbacks.push(() => { ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
  let data = nbviz.getCountryData()
  updateMap(data)
})

1

此匿名函数在核心模块中在数据更新时被调用。

现在我们已经构建了我们的诺贝尔数据可视化的地图组件,让我们在继续展示用户输入如何驱动可视化之前总结一下我们学到的内容。

摘要

D3 映射是一个丰富的领域,有许多不同的投影和大量的实用方法来帮助操纵几何图形。但是构建地图遵循一个相当标准的过程,就像本章演示的那样:首先选择你的投影方式——比如墨卡托投影或者通常用于美国地图绘制的阿尔伯斯圆锥投影。然后使用这个投影创建一个 D3 的 path 生成器,将 GeoJSON 特征转换为 SVG 路径,创建你看到的地图。GeoJSON 通常从更高效的 TopoJSON 数据中提取。

本章还展示了使用 D3 如何轻松交互地突出显示您的地图并处理鼠标移动。综合起来,这些基本技能应该使您能够开始构建自己的地图可视化。

现在,我们已经构建了所有基于 SVG 的图形元素,让我们看看 D3 如何与传统 HTML 元素一起工作,通过构建我们的获奖者列表和个人传记框。

^(1) 例如,地理投影的数学可以迅速变得复杂。

^(2) 我使用并强烈推荐开源的QGIS

^(3) Python 的topojson.py和 TopoJSON 命令行程序。

^(4) 正如我们将看到的那样,它确实缺少我们的几个诺贝尔奖国家,但这些国家太小,无法点击,我们有它们中心的坐标,可以在视觉上叠加标记。

^(5) 请查看Mike Bostock 的这个网站以获取一个非常酷的例子。

^(6) 通过全局安装,您可以在任何目录中使用geo2topo命令。

^(7) D3 的扩展投影集是 D3 的一个扩展部分,不在主库中。

^(8) 请参阅https://oreil.ly/oAppn以查看一个很好的演示。

^(9) 请查看D3 GitHub获取完整列表。

第二十章:可视化个别获奖者

我们希望我们的诺贝尔奖可视化(Nobel-viz)包括一个当前选定获奖者列表和一个生物框(也称为 bio-box),用于显示个别获奖者的详细信息(参见图 20-1)。通过点击列表中的获奖者,用户可以在生物框中查看其详细信息。在本章中,我们将看到如何构建列表和生物框,如何在用户选择新数据时重新填充列表(通过菜单栏过滤器),以及如何使列表可点击。

dpj2 2001

图 20-1. 章节目标元素

正如本章所示,D3 不仅用于构建 SVG 可视化。您可以将数据绑定到任何 DOM 元素,并使用它来更改其属性和属性或其事件处理回调函数。D3 的数据连接和事件处理(通过on方法实现)非常适用于本章的可点击列表和选择框等常见用户界面。^(1)

让我们首先处理获奖者列表及其如何在当前选定获奖者数据集中构建。

创建列表

我们使用 HTML 表格构建获奖者列表(参见图 20-1),其中包含年份、类别和姓名列。此列表的基本骨架在 Nobel-viz 的 index.xhtml 文件中提供:

<!DOCTYPE html>
<meta charset="utf-8">
<body>
...
    <div id="nobel-list">
      <h2>Selected winners</h2>
      <table>
        <thead>
          <tr>
            <th id='year'>Year</th>
            <th id='category'>Category</th>
            <th id='name'>Name</th>
          </tr>
        </thead>
        <tbody>
        </tbody>
      </table>
    </div>
...
</body>

我们将在 style.css 中使用少量 CSS 来样式化这个表格,调整列的宽度和字体大小:

/* WINNERS LIST */
#nobel-list { overflow: scroll; overflow-x: hidden; } ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)

#nobel-list table{ font-size: 10px; }
#nobel-list table th#year { width: 30px }
#nobel-list table th#category { width: 120px }
#nobel-list table th#name { width: 120px }

#nobel-list h2 { font-size: 14px; margin: 4px;
text-align: center }

1

overflow: scroll 将列表内容裁剪(保持在nobel-list容器内),并添加滚动条,以便访问所有获奖者。 overflow-x: hidden 抑制了水平滚动条的添加。

为了创建列表,我们将为当前数据集中的每个获奖者向表格的<tbody>元素添加<tr>行元素(每列包含一个<td>数据标签),生成如下内容:

        ...
        <tbody>
          <tr>
            <td>2014</td>
            <td>Chemistry</td>
            <td>Eric Betzig</td>
          </tr>
          ...
        </tbody>
        ...

要创建这些行,我们的中心onDataChange在应用初始化时将调用updateList方法,并在用户应用数据过滤器并更改获奖者列表时随后调用(参见“基本数据流”)。updateList接收到的数据将具有以下结构:

// data =
[{
  name:"C\u00e9sar Milstein",
  category:"Physiology or Medicine",
  gender:"male",
  country:"Argentina",
  year: 1984
  _id: "5693be6c26a7113f2cc0b3f4"
 },
 ...
]

示例 20-1 展示了updateList方法。首先按年份对接收到的数据进行排序,然后在移除任何现有行后,用于构建表格行。

示例 20-1. 创建选定获奖者列表
let updateList = function (data) {
  let tableBody, rows, cells
  // Sort the winners' data by year
  data = data.sort(function (a, b) {
    return +b.year - +a.year
  })
  // select table-body from index.xhtml
  tableBody = d3.select('#nobel-list tbody')
  // create place-holder rows bound to winners' data
  rows = tableBody.selectAll('tr').data(data)

  rows.join( ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
    (enter) => {
      // create any new rows required
      return enter.append('tr').on('click', function (event, d) { ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
        console.log('You clicked a row ' + JSON.stringify(d))
        displayWinner(d)
      })
    },
    (update) => update,
    (exit) => {
      return exit ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
        .transition()
        .duration(nbviz.TRANS_DURATION)
        .style('opacity', 0)
        .remove()
    }
  )

  cells = tableBody ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/4.png)
    .selectAll('tr')
    .selectAll('td')
    .data(function (d) {
      return [d.year, d.category, d.name]
    })
  // Append data cells, then set their text
  cells.join('td').text(d => d) ![5](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/5.png)
  // Display a random winner if data is available
  if (data.length) { ![6](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/6.png)
    displayWinner(data[Math.floor(Math.random() * data.length)])
  }
}

1

一个现在熟悉的连接模式,使用绑定的获奖者数据创建和更新列表项。

2

当用户点击一行时,此点击处理函数将将绑定到该行的获奖者数据传递给displayWinner方法,后者将相应地更新生物框。

3

这个自定义的exit函数在两秒的过渡期内淡出任何多余的行,将它们的不透明度降低到零,然后移除它们。

4

首先,我们使用获奖者的数据创建一个包含年份、类别和姓名的数据数组,这些数据将用于创建行的<td>数据单元格…​

5

…​然后我们将这个数组与行的数据单元格(td)连接起来,并用它来设置它们的文本。

6

每次数据更改时,我们从新数据集中随机选择一个获奖者,并在生物框中显示他或她。

当用户将光标移动到我们的获奖者表格中的一行上时,我们希望突出显示该行,并且还要更改指针的样式为cursor,以指示该行可以点击。以下 CSS 代码解决了这些细节问题,并添加到了我们的style.css文件中:

#nobel-list tr:hover{
    cursor: pointer;
    background: lightblue;
}

我们的updateList方法在点击行或数据更改时(随机选择)调用displayWinner方法以构建获奖者的传记框。现在让我们看看如何构建生物框。

构建生物框

生物框使用获奖者对象填充一个小型传记的细节。生物框的 HTML 骨架提供在index.xhtml文件中,包括用于生物元素的内容块和一个readmore页脚,提供了一个指向获奖者更多信息的维基百科链接:

<!DOCTYPE html>
<meta charset="utf-8">
<body>
...
    <div id="nobel-winner">
      <div id="picbox"></div>
      <div id='winner-title'></div>
      <div id='infobox'>
        <div class='property'>
          <div class='label'>Category</div>
          <span name='category'></span>
        </div>
        <div class='property'>
          <div class='label'>Year</div>
          <span name='year'></span>
        </div>
        <div class='property'>
          <div class='label'>Country</div>
          <span name='country'></span>
        </div>
      </div>
      <div id='biobox'></div>
      <div id='readmore'>
        <a href='#'>Read more at Wikipedia</a></div>
    </div>
...
</body>

style.css中的一点点 CSS 设置了列表和生物框元素的位置,调整了它们的内容块大小,并提供了边框和字体的具体设置:

/* WINNER INFOBOX */

#nobel-winner {
    font-size: 11px;
    overflow: auto;
    overflow-x: hidden;
    border-top: 4px solid;
}

#nobel-winner #winner-title {
    font-size: 12px;
    text-align: center;
    padding: 2px;
    font-weight: bold;
}

#nobel-winner #infobox .label {
    display: inline-block;
    width: 60px;
    font-weight: bold;
}

#nobel-winner #biobox { font-size: 11px; }
#nobel-winner #biobox p { text-align: justify; }

#nobel-winner #picbox {
    float: right;
    margin-left: 5px;
}
#nobel-winner #picbox img { width:100px; }

#nobel-winner #readmore {
    font-weight: bold;
    text-align: center;
}

确定我们的内容块就位后,我们需要回调我们的数据 API 以获取填充它们所需的数据。示例 20-2 展示了用于构建生物框的displayWinner方法。

示例 20-2. 更新所选获奖者的传记框
let displayWinner = function (wData) {
  // store the winner's bio-box element
  let nw = d3.select('#nobel-winner')

  nw.select('#winner-title').text(wData.name)
  nw.style('border-color', nbviz.categoryFill(wData.category)) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)

  nw.selectAll('.property span').text(function (d) { ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
    var property = d3.select(this).attr('name')
    return wData[property]
  })

  nw.select('#biobox').html(wData.mini_bio)
  // Add an image if available, otherwise remove the old one
  if (wData.bio_image) { ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
    nw.select('#picbox img')
      .attr('src', 'static/images/winners/' + wData.bio_image)
      .style('display', 'inline')
  } else {
    nw.select('#picbox img').style('display', 'none')
  }

  nw.select('#readmore a').attr( ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/4.png)
    'href',
    'http://en.wikipedia.org/wiki/' + wData.name
  )
}

1

我们的nobel-winner元素有一个顶部边框(CSS: border-top: 4px solid),我们将根据获奖者的类别使用nbviz_core.js中定义的categoryFill方法来着色。

2

我们选择所有具有类名propertydiv<span>标签。这些标签的形式是<span name=*category*></span>。我们使用 span 的name属性从我们的诺贝尔获奖者数据中检索正确的属性,并将其用于设置标签的文本。

3

在这里,如果有获奖者的图片可用,我们会设置其src(源)属性。如果没有可用的图片,我们使用图像标签的display属性将其隐藏(设置为none),或者显示它(默认为inline)。

4

我们的获奖者姓名是从维基百科上获取的,可以用于检索他们的维基百科页面。

更新获奖者名单

当详细信息(获奖者名单和简介)模块被导入时,它会将回调函数附加到核心模块的回调数组中。当响应用户交互更新数据时,这个回调函数被调用,并且使用 Crossfilter 的国家维度更新列表,显示新的国家数据:

nbviz.callbacks.push(() => { ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
  let data = nbviz.countryDim.top(Infinity)
  updateList(data)
})

1

当数据更新时,这个匿名函数在核心模块中被调用。

现在我们已经看到如何通过允许用户显示获奖者传记来为我们的 Nobel-viz 增添一些个性化内容,让我们在进入菜单栏构建的细节之前总结本章内容。

总结

在本章中,我们看到了 D3 如何用于构建传统的 HTML 结构,而不仅仅是 SVG 图形。D3 不仅能够构建列表、表格等内容,就像显示圆圈或改变线条旋转一样自如。只要有需要通过网页元素反映的变化数据,D3 通常能够优雅而高效地解决问题。

在我们完成获奖者名单和传记框的介绍后,我们已经看到了如何构建 Nobel-viz 中的所有视觉元素。现在只剩下看看如何构建可视化菜单栏以及它如何通过这些视觉元素反映数据集和奖项度量的变化。

^(1) 我们将在第二十一章中介绍选择框(作为数据过滤器)。

第二十一章:菜单栏

前几章展示了如何构建我们交互式诺贝尔奖可视化的视觉组件:时间图表用于显示所有诺贝尔奖得主按年份排序,地图显示地理分布,列表显示当前选定的获奖者,条形图比较各国的绝对和人均获奖情况。在本章中,我们将看到用户如何通过使用选择器和按钮(参见图 21-1)与可视化进行交互,以创建一个经过筛选的数据集,然后这些数据将反映在视觉组件中。例如,在类别选择框中选择物理学,筛选器将只显示诺贝尔奖可视化元素中的物理奖获得者。我们的菜单栏中的筛选器是累积的,因此我们可以选择只有来自法国的女性化学家曾获诺贝尔奖的人。^(1)

dpj2 2101

图 21-1. 本章目标菜单栏

在接下来的章节中,我将向您展示如何使用 D3 构建菜单栏,以及如何使用 JavaScript 回调来响应用户驱动的变化。

使用 D3 创建 HTML 元素

许多人认为 D3 是一个专门用于创建由图形基元组成的 SVG 可视化工具。虽然 D3 在这方面非常出色(是最好的),但它同样擅长创建传统的 HTML 元素,如表格或选择框。对于像层次菜单这样的复杂、数据驱动的 HTML 结构,D3 的嵌套数据连接是创建 DOM 元素和处理用户选择回调的理想方式。

在第二十章中,我们看到从选定数据集创建table行或填写获奖者数据的传记框是多么容易。在本章中,我们将展示如何基于变化的数据集填充选择器的选项,以及如何将回调函数附加到用户界面元素,如选择器和单选框。

提示

如果你有稳定的 HTML 元素(例如,一个选择框,其选项不依赖于变化的数据),最好是先用 HTML 编写它们,然后再使用 D3 绑定任何需要处理用户输入的回调函数。与 CSS 样式一样,你应该尽可能多地在原生 HTML 中完成。这样可以保持代码库的清洁,并且易于其他开发人员和非开发人员理解。在本章中,我会稍微放宽这一规则,来演示如何创建 HTML 元素,但通常情况下,这确实是最佳实践。

构建菜单栏

如在“HTML 骨架”中所述,我们的 Nobel-viz 是建立在 HTML <div>占位符上的,通过 JavaScript 和 D3 扩展。如例 21-1 所示,我们的菜单栏建立在nobel-menu <div>上,放置在主图表持有者的上方,包括三个选择器过滤器(按获奖者类别、性别和国家)和一对单选按钮来选择国家获奖指标(绝对或人均)。

例 21-1。菜单栏的 HTML 骨架
<!-- ... -->
<body>
<!-- ... -->
  <!-- THE PLACEHOLDERS FOR OUR VISUAL COMPONENTS  -->
    <div id="nbviz">
      <!-- BEGIN MENU BAR -->
      <div id="nobel-menu">
        <div id="cat-select">
          Category
          <select></select>
        </div>
        <div id="gender-select">
          Gender
          <select>
            <option value="All">All</option>
            <option value="female">Female</option>
            <option value="male">Male</option>
          </select>
        </div>
        <div id="country-select">
          Country
          <select></select>
        </div>
        <div id='metric-radio'>
          Number of Winners:&nbsp;
          <form>
            <label>absolute
              <input type="radio" name="mode" value="0" checked>
            </label>
            <label>per-capita
              <input type="radio" name="mode" value="1">
            </label>
          </form>
        </div>
      </div>
      <!-- END MENU BAR  -->
  <div id='chart-holder'>
<!-- ... -->
</body>

现在我们将依次添加 UI 元素,首先是选择器过滤器。

构建类别选择器

为了构建类别选择器,我们需要一个选项字符串列表。让我们使用nbviz_core.js中定义的CATEGORIES列表创建该列表:

import nbviz from './nbviz_core.mjs'

let catList = [nbviz.ALL_CATS].concat(nbviz.CATEGORIES) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)

1

通过连接['所有类别', '化学', '经济学', ... ]列表和['所有类别']列表以创建类别选择器的列表。

现在我们将使用这个类别列表来制作选项标签。我们首先使用 D3 来获取#cat-select选择标签:

//...
    let catSelect = d3.select('#cat-select select');

有了catSelect,让我们使用标准的 D3 数据连接来将我们的catList类别列表转换为 HTML option标签:

catSelect.selectAll('option')
   .data(catList)
   .join('option') ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
   .attr('value', d => d) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
   .html(d => d);

1

数据绑定后,为每个catList成员附加一个option

2

我们设置optionvalue属性和文本为一个类别(例如,<option value="Peace">和平</option>)。

前述append操作的结果是以下cat-select DOM 元素:

<div id="cat-select">
  "Category "
  <select>
    <option value="All Categories">All Categories</option>
    <option value="Chemistry">Chemistry</option>
    <option value="Economics">Economics</option>
    <option value="Literature">Literature</option>
    <option value="Peace">Peace</option>
    <option value="Physics">Physics</option>
    <option value="Physiology or Medicine">
    Physiology or Medicine</option>
  </select>
</div>

现在我们有了选择器,我们可以使用 D3 的on方法来附加一个事件处理回调函数,当选择器被改变时触发:

catSelect.on('change', function(d) {
        let category = d3.select(this).property('value'); ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
        nbviz.filterByCategory(category); ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
        nbviz.onDataChange(); ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
    });

1

this是选择标签,value属性是选定类别选项。

2

我们调用nbviz_core.js中定义的filterByCategory方法来过滤所选类别的奖项数据集。

3

onDataChange触发了将更新以反映我们新过滤的数据集的可视组件的方法。

图 21-2 是我们选择回调的示意图。选择物理学调用我们附加到选择器变更事件的匿名回调函数。此函数启动了 Nobel-viz 可视元素的更新。

dpj2 2102

图 21-2。类别选择回调

在类别选择器的回调中,我们首先调用filterByCategory方法^(2)来选择仅物理学获奖者,并调用onDataChange方法来触发所有可视化组件的更新。在适用的情况下,这些将反映更改后的数据。例如,地图的分布环形指示器将重新调整大小,在没有诺贝尔物理学获奖者的国家中会消失。

添加性别选择器

我们已经在 index.xhtml 中的菜单栏描述中添加了性别选择器及其选项的 HTML:

<!-- ... -->
        <div id="gender-select">
          Gender
          <select>
            <option value="All">All</option>
            <option value="female">Female</option>
            <option value="male">Male</option>
          </select>
        </div>
<!-- ... -->

现在我们只需选择性别 select 标签,并添加一个回调函数来处理用户的选择。我们可以很容易地通过 D3 的 on 方法实现这一点:

    d3.select('#gender-select select')
        .on('change', function(d) {
            let gender = d3.select(this).property('value');
            if(gender === 'All'){
                nbviz.genderDim.filter(); ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
            }
            else{
                nbviz.genderDim.filter(gender);
            }
            nbviz.onDataChange();
        });

1

调用性别维度的过滤器,不带参数则将其重置以允许所有性别。

首先,我们选择选择器的选项值。然后,我们使用此值来过滤当前的数据集。最后,我们调用 onDataChange 方法来触发 Nobel-viz 可视组件由新数据集引起的任何变化。

要放置性别 select 标签,我们使用了一点 CSS,给它左边距 20 像素:

#gender-select{margin-left:20px;}

添加国家选择器

添加国家选择器比添加类别和性别选择器更复杂。诺贝尔奖按国家分布有长尾效应(参见图 17-1),许多国家只有一两个奖项。我们可以将所有这些包括在我们的选择器中,但这将使其变得相当长和笨重。一个更好的方法是为单个和双重获奖国家添加分组,以保持可管理的选择项数量,并向图表添加一些叙述,即小奖项随时间的分布,这可能反映出诺贝尔奖分配趋势的变化。(参见 3)。

为了添加我们的单个和双重获奖国家组,我们需要使用交叉过滤的国家维度获取每个国家的组大小。这意味着在我们的诺贝尔奖数据集加载后,将其放入一个 nbviz.initUI 方法中,该方法在我们主要的 nbviz_main.js 脚本中的交叉过滤器维度创建完成后调用(参见“使用交叉过滤器过滤数据”)。

下面的代码创建一个选择列表。三次或更多获奖的国家会有自己的选择项,出现在“所有获奖者”选择项下方。单个和双重获奖的国家会被添加到各自的列表中,这些列表将用于在用户从选择器选项中选择“单个获奖国家”或“双重获奖国家”时过滤数据集。

export let initMenu = function() {
    let ALL_WINNERS = 'All Winners';
    let SINGLE_WINNERS = 'Single Winning Countries';
    let DOUBLE_WINNERS = 'Double Winning Countries';

    let nats = nbviz.countrySelectGroups = nbviz.countryDim
        .group().all() ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
        .sort(function(a, b) {
            return b.value - a.value; // descending
        });

    let fewWinners = {1:[], 2:[]}; ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
    let selectData = [ALL_WINNERS];

    nats.forEach(function(o) {
        if(o.value > 2){ ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
            selectData.push(o.key);
        }
        else{
            fewWinners[o.value].push(o.key); ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/4.png)
        }
    });

    selectData.push(
        DOUBLE_WINNERS,
        SINGLE_WINNERS
    );
    //... })

1

按 ({key:"United States", value:336}, …​) 排序的组数组,其中 value 是该国家的获奖者数量。

2

一个包含用于存储单个和双重获奖者的列表的对象。

3

有两名以上获奖者的国家在 selectData 列表中有自己的位置。

4

基于值为 1 或 2 的组大小,将单个和双重获奖的国家添加到各自的列表中。

现在我们有了带有对应fewWinners数组的selectData列表,我们可以用它来创建国家选择器的选项。我们首先使用 D3 获取国家选择器的select标签,然后使用标准数据绑定将选项添加到其中:

let countrySelect = d3.select('#country-select select');

countrySelect
    .selectAll("option")
    .data(selectData)
    .join("option")
    .attr("value", (d) => d)
    .html((d) => d);

添加了我们的selectData选项后,选择器看起来像图 21-3。

dpj2 2103

图 21-3. 按国家选择奖项的选择器

现在我们只需要一个回调函数,当选择一个选项时触发,通过国家过滤我们的主数据集。以下代码展示了如何完成。首先,我们获取选择的selectvalue属性(1),一个国家或者ALL_WINNERSDOUBLE_WINNERSSINGLE_WINNERS之一。然后,我们构造一个国家列表,发送到我们的国家过滤方法nbviz.filterByCountries(在nbviz_core.js中定义):

countrySelect.on('change', function(d) {

        let countries;
        let country = d3.select(this).property('value');

        if(country === ALL_WINNERS){ ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
            countries = [];
        }
        else if(country === DOUBLE_WINNERS){
            countries = fewWinners[2];
        }
        else if(country === SINGLE_WINNERS){
            countries = fewWinners[1];
        }
        else{
            countries = [country];
        }

        nbviz.filterByCountries(countries); ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
        nbviz.onDataChange(); ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
    });

1

根据country字符串创建一个countries数组的条件语句。这个数组可以为空,单值,或包含一个fewWinners数组中的国家。

2

调用filterByCountries来使用国家数组过滤我们的主要诺贝尔获奖者数据集。

3

触发更新所有诺贝尔可视化元素。

filterByCountries函数显示在示例 21-2 中。一个空的countryNames参数将重置过滤器;否则,我们将按countryNames中的所有国家过滤国家维度countryDim 1

示例 21-2. 按国家过滤函数
nbviz.filterByCountries = function(countryNames) {

    if(!countryNames.length){ ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
        nbviz.countryDim.filter();
    }
    else{
        nbviz.countryDim.filter(function(name) { ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
            return countryNames.indexOf(name) > -1;
        });
    }

    if(countryNames.length === 1){ ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
        nbviz.activeCountry = countryNames[0];
    }
    else{
        nbviz.activeCountry = null;
    }
};

1

如果countryNames数组为空(用户选择所有国家),则重置过滤器。

2

在这里,我们在crossfilter国家维度上创建一个过滤器函数,如果一个国家在countryNames列表中(包含单个国家或所有单个或双赢家),则返回true

3

记录任何单个选择的国家,例如在地图和条形图中突出显示。

现在,我们已经为类别、性别和国家维度构建了过滤选择器,现在我们只需要添加回调函数,以处理获奖度量单选按钮的更改。

连接度量单选按钮

指标单选按钮已在 HTML 中构建完成,由带有radio输入的表单组成:

        <div id='metric-radio'> Number of Winners:&nbsp; ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
          <form>
            <label>absolute <input
               type="radio" name="mode" value="0" checked> ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)
            </label>
            <label>per-capita <input type="radio" name="mode" value="1">
            </label>
          </form>
        </div>

1

使用&nbsp;在表单和其标签之间创建一个不间断的空格。

2

类型为radio的输入共享相同的名称(在本例中为mode),它们被分组在一起,激活其中一个将取消激活其他所有输入。它们通过值(在本例中为01)进行区分。这里我们使用checked属性来初始激活值0

有了单选按钮表单,我们只需选择所有其输入,并添加一个回调函数来处理任何按下按钮触发的更改:

d3.selectAll('#metric-radio input').on('change', function() {
        var val = d3.select(this).property('value');
        nbviz.valuePerCapita = parseInt(val); ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
        nbviz.onDataChange();
    });

1

在调用onDataChange并触发可视元素重新绘制之前,更新valuePerCapita的值。

我们使用我们的valuePerCapita整数存储按钮的当前状态。当用户选择单选框时,此值将更改,并使用onDataChange触发具有新度量的重新绘制。

现在我们为我们的 Nobel-viz 添加了菜单栏元素,允许用户细化显示的数据集并深入到他们最感兴趣的子集。

摘要

在本章中,我们看到如何将选择器和单选按钮元素添加到我们的诺贝尔奖可视化中。还有许多其他用户界面 HTML 标记,如按钮组、复选框组、时间选择器和普通按钮。^(4) 但是,实现这些控件涉及与本章所示的相同模式。使用数据列表来附加和插入 DOM 元素,根据需要设置属性,并将回调函数绑定到任何更改事件。这是一种非常强大的方法,非常适合与 D3(和 JS)习惯用法相结合,如方法链和匿名函数。它将很快成为您 D3 工作流程的自然组成部分。

^(1) 值得注意的是,玛丽·居里和她的女儿伊莲·居里-居里拥有这一荣誉。

^(2) 定义在 nbviz_core.js 脚本中。

^(3) 它确实显示,在单个获奖者中,和平诺贝尔奖占主导地位,其次是文学奖。

^(4) 在 HTML5 中还有原生滑块,以前要依赖于 jQuery 插件。

第二十二章:结论

尽管本书有一个引导性的叙述——将一些基本的维基百科 HTML 页面转化为现代、交互式的 JavaScript Web 可视化——它旨在根据需要随时查阅。不同的部分是独立的,允许数据集以其各个阶段的存在,并可以独立使用。在继续前进之前,让我们简要回顾一下之前涵盖的内容,并提出一些未来可视化工作的想法。

回顾

本书分为五个部分。第一部分介绍了基本的 Python 和 JavaScript 数据可视化工具包,而接下来的四部分则展示了如何获取原始数据、清理数据、探索数据,最后将其转化为现代 Web 可视化。这个精炼和转化的过程以数据可视化挑战为基础:将基本的维基百科诺贝尔奖列表转化为更具吸引力和信息性的数据集。现在让我们总结每个部分的关键教训。

第 I 部分:基本工具包

我们的基本工具包包括:

  • 一种连接 Python 和 JavaScript 的语言学习桥梁。设计它的初衷是平滑过渡这两种语言,突出它们的许多相似之处,并为现代数据可视化的双语过程设定场景。Python 和 JavaScript 有更多共同点,这使得在它们之间切换变得更加轻松。

  • 能够轻松读写主要数据格式(例如 JSON 和 CSV)和数据库(包括 SQL 和 NoSQL)是 Python 的一大优势。我们看到,在 Python 中传递数据如此简单,可以在此过程中转换格式并更改数据库。数据的流动是任何数据可视化工具链的主要润滑剂。

  • 我们讨论了开始制作现代、交互式、基于浏览器的数据可视化所需的基本 Web 开发(webdev)技能。通过专注于单页面应用程序的概念,而不是构建整个网站,我们最大程度地减少了传统的 Web 开发,并将重点放在用 JavaScript 编程您的视觉创作上。可伸缩矢量图形(SVG)的介绍,作为 D3 可视化的主要构建块,为我们在第五部分创建诺贝尔奖可视化奠定了基础(part05.xhtml#part_viz)。

第 II 部分:获取您的数据

在本书的这一部分中,我们看到如何使用 Python 从 Web 获取数据,假设数据可视化者没有提供一个干净、整洁的数据文件:

  • 如果你幸运的话,在一个易于使用的数据格式(即 JSON 或 CSV)的开放 URL 上,可能有一个干净的文件,只需进行简单的 HTTP 请求即可获取。或者,可能有一个专门的 Web API 用于您的数据集,如果幸运的话,它可能是一个 RESTful API。作为示例,我们看了如何使用 Twitter API(通过 Python 的 Tweepy 库)。我们还看到了如何使用 Google 电子表格,这是数据可视化中广泛使用的数据共享资源。

  • 当感兴趣的数据以人类可读形式出现在网络上,通常是在 HTML 表格、列表或分层内容块中时,情况会更加复杂。在这种情况下,你必须求助于抓取,获取原始 HTML 内容,然后使用解析器使其嵌入内容可用。我们看到了如何使用 Python 的轻量级 Beautiful Soup 抓取库以及更多功能强大和重量级的 Scrapy,后者是 Python 抓取领域的明星。

第三部分:使用 pandas 清理和探索数据

在这部分中,我们把 pandas 这一强大的 Python 编程电子表格工具应用于清理和探索数据集的问题上。我们首先看到 pandas 是 Python NumPy 生态系统的一部分,利用了非常快速、强大的低级别数组处理库的力量,但使其易于使用。重点是使用 pandas 清理和探索我们的诺贝尔奖数据集:

  • 大多数数据,即使来自官方网络 API,也是脏乱的。使其变得清洁和可用将占据你作为数据可视化者更多的时间,这可能超出了你的预期。以诺贝尔奖数据集为例,我们逐步清理它,搜索不可靠的日期,异常的数据类型,缺失字段以及所有需要在开始探索并将数据转换为可视化之前清理的常见问题。

  • 手头有我们尽可能清洁的诺贝尔奖数据集后,我们看到了使用 pandas 和 Matplotlib 进行交互式数据探索是多么容易,可以轻松创建内联图表,灵活地切片数据,并且总体上对数据有了更深的了解,同时寻找那些你想要通过可视化呈现的有趣信息。

第四部分:数据交付

在这部分中,我们看到了使用 Flask 创建最小数据 API 是多么容易,可以将数据静态和动态地提供给 Web 浏览器。

首先,我们看到如何使用 Flask 来提供静态文件,然后如何自行构建基本的数据 API,从本地数据库提供数据。Flask 的极简主义允许你在 Python 数据处理成果与最终在浏览器上可视化之间创建非常薄的数据服务层。开源软件的优势在于,你通常可以找到比你更好地解决问题的稳健易用的库。在本部分的第二章,我们看到了如何使用 Python 的最佳库(Flask)轻松创建健壮灵活的 RESTful API,准备好在线提供你的数据。我们还介绍了如何使用 Heroku 进行轻松的在线部署,这是 Python 爱好者喜爱的平台。

第五部分:使用 D3 和 Plotly 可视化你的数据

在本部分的第一章中,我们看到了如何通过 pandas 驱动的探索结果(如图表或地图)并将它们放在它们应该展示的地方——网页上。Matplotlib 可以生成出版标准的静态图表,而 Plotly 则为用户提供控件和动态图表。我们还看到了如何将 Plotly 图表直接从 Jupyter 笔记本放入网页中。

我认为可以说,学习 D3 是本书中最雄心勃勃的部分,但我决心展示如何构建多元素可视化,例如您可能最终被雇用去制作的可视化。D3 的一大乐趣在于可以轻松找到大量的示例在线,但大多数示例仅演示单一技术,并且几乎没有展示如何编排多个视觉元素的示例。在这些 D3 章节中,我们看到了如何同步更新一个时间线(包含所有的诺贝尔奖),一个地图,一个条形图和一个列表,当用户过滤诺贝尔奖数据集或更改获奖指标(绝对或人均)时。

掌握这些章节中展示的核心主题应该使您能够释放您的想象力并通过实践学习。我建议选择一些您关心的数据,并围绕它设计一个 D3 创作。

未来的进展

正如提到的,Python 和 JavaScript 的数据处理和可视化生态系统目前非常活跃,并且是建立在非常坚实的基础之上。

虽然获取和清理数据集在第二部分和第九章中逐步改进,随着您的技艺(例如,您的 pandas 技能)的提高,变得更加容易,Python 却大量推出了新的强大的数据处理工具。Python 的维基上有一份相当全面的列表。以下是您可能想要用来创建一些可视化的一些想法。

可视化社交媒体网络

社交媒体的出现提供了大量有趣的数据,通常可以从 Web API 中获取或者很容易被抓取。还有一些经过策划的社交媒体数据集,如斯坦福大学的大型网络数据集合加州大学尔湾分校的收藏。这些数据集可以为网络可视化的探索提供一个简单的测试场所,这是一个日益流行的领域。

Python 网络分析的两个最流行的库是graph-toolNetworkX。虽然 graph-tool 更加优化,但 NetworkX 可以说更加用户友好。这两个库都可以生成通用的GraphMLGML格式的图形。D3 无法直接读取 GML 文件,但很容易将它们转换为 JSON 格式以供读取。您可以在这篇博客文章中找到一个很好的例子,其中附带了在GitHub上的代码。请注意,在 D3 版本 4 中,forceSimulation API 发生了变化。您可以在Pluralsight找到对新 API 的简要介绍,该 API 使用 forceSimulation 对象来跟踪事物。

机器学习可视化

当前机器学习颇受青睐,Python 提供了一套出色的工具,让您可以开始分析和挖掘数据,使用从监督到无监督、从基本回归算法(如线性或逻辑回归)到更加奇特前沿的算法族(如随机森林)的广泛算法。参见这个不错的导览以了解不同风格的算法。

在 Python 的机器学习工具中,首屈一指的是scikit-learn,它是 NumPy 生态系统的一部分,同时也依赖于 SciPy 和 Matplotlib。scikit-learn 为高效的数据挖掘和数据分析提供了一个令人惊叹的资源。几年前还需要花费几天甚至几周来开发的算法,现在只需一行导入就能获得,设计良好,易于使用,并且能够在几行代码中获得有用的结果。

像 scikit-learn 这样的工具使您能够发现数据中存在的深层次相关性。在 R2D3 上有一个不错的演示,介绍了一些机器学习技术,并使用 D3 来可视化过程和结果。这是 D3 掌握带来的创造自由的一个很好的例子,以及优秀的网络数据可视化正在推动界限,创造出以前不可能的新颖可视化效果,并且当然这些都是对每个人都可用的。

在 IPython(Jupyter)的 GitHub 仓库中有一个很棒的集合,涵盖了统计、机器学习和数据科学的 IPython 笔记本。其中许多演示了可在您自己的工作中进行适应和扩展的可视化技术。

总结思考

在前一节中提出的建议只是揭示了你可能利用新学到的 Python 和 JavaScript 数据可视化技能的表面。希望本书为你在这个领域建立网页数据可视化工作提供了坚实的基础,或者只是为了满足个人的兴趣。能够利用 Python 强大的数据处理和通用能力与 JavaScript(尤其是 D3)日益强大和成熟的可视化库,代表了我所知道的最丰富的数据可视化技术栈。这一领域的技能已经非常值钱,但变化的速度和兴趣的规模正在迅速增加。希望你发现这个充满活力和新兴的领域像我一样充满成就感。

附录 A. D3 的进入/退出模式

如 “使用数据更新 DOM” 所示,D3 现在有了一个更用户友好的 join 方法,来替代基于 enterexitremove 方法的旧数据连接实现模式。join 方法是 D3 的一个很好的补充,但在线上有成千上万的使用旧数据连接模式的示例。为了使用/转换这些示例,了解 D3 连接数据时底层发生的事情会有所帮助。

为了演示 D3 的数据连接,让我们深入了解当 D3 连接数据时的底层原理。让我们从我们没有条形图的图表开始,SVG 画布和图表组都已准备好:

...
    <div id="nobel-bar">
      <svg width="600" height="400">
        <g class="chart" transform="translate(40, 20)"></g>
      </svg>
    </div>
...

为了使用 D3 连接数据,我们首先需要以正确的形式准备一些数据。通常这将是一个对象数组,就像我们条形图的 nobelData

var nobelData = [
    {key:'United States', value:336},
    {key:'United Kingdom', value:98},
    {key:'Germany', value:79},
    ...
]

D3 数据连接分为两个阶段。首先,我们使用 data 方法添加要连接的数据,然后使用 join 方法执行连接。

要将我们的诺贝尔数据添加到一组条形图中,我们需要做以下操作。首先,选择一个容器来放置我们的条形图,这里是我们的 chart 类 SVG 组。

然后我们定义容器,这里是一个类为 bar 的 CSS 选择器:

var svg = d3.select('#nobel-bar .chart');

var bars =  svg.selectAll('.bar')
              .data(nobelData);

现在我们来看一下 D3 的 data 方法稍微反直觉的一面。我们的第一个 select 返回了我们 nobel-bar SVG 画布中的 chart 组,但第二个 selectAll 返回了所有类为 bars 的元素,但实际上并没有。如果没有条形图,我们到底将数据绑定到什么?答案是,在幕后,D3 会记录哪些 DOM 元素已绑定到 nobelData,以及哪些没有。接下来,我们将看看如何利用这一点,使用基本的 enter 方法。

进入方法

D3 的 enter 方法(以及其姊妹 exit)既是 D3 强大功能和表现力的基础,也是许多混淆的根源。尽管如前所述,新的 join 方法简化了事务,但如果您真的希望提高 D3 技能,了解 enter 方法也是值得的。现在让我们通过一个非常简单和缓慢的演示来介绍它。

我们将从一个经典简单的小示例开始,为我们的诺贝尔奖数据的每个成员添加一个条形矩形。我们将使用前六个诺贝尔奖获奖国家作为我们的绑定数据:

var nobelData = [
    {key:'United States', value:200},
    {key:'United Kingdom', value:80},
    {key:'France', value:47},
    {key:'Switzerland', value:23},
    {key:'Japan', value:21},
    {key:'Austria', value:12}
];

手头有了我们的数据集,让我们先使用 D3 抓取图表组,并将其保存到一个 svg 变量中。我们将用它来选择目前不存在的 bar 类元素:

var svg = d3.select('#nobel-bar .chart');

var bars = svg.selectAll('.bar')
    .data(nobelData);

虽然bars选择为空,但在幕后,D3 已经记录了我们刚刚绑定到它的数据。此时,我们可以利用这一事实和enter方法来使用我们的数据创建一些条形图。在我们的bars选择上调用enter返回了所有未绑定到条形图的数据的子选择(在本例中为nobelData),因为原始选择中没有条形图(我们的图表为空),所有数据都是未绑定的,因此enter返回一个大小为六的输入选择(基本上是所有未绑定数据的占位符节点):

bars = bars.enter(); # returns six placeholder nodes

我们可以使用bars中的占位符节点创建一些 DOM 元素——在我们的情况下,一些条形图。我们不会费心试图将它们放置正确(根据惯例,y 轴从屏幕顶部向下),但我们会使用数据值和索引来设置条形图的位置和高度:

bars.append('rect')
    .classed('bar', true)
    .attr('width', 10)
    .attr('height', function(d){return d.value;}) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)
    .attr('x', function(d, i) { return i * 12; });

1

如果您向 D3 的设置器方法(attrstyle等)提供回调函数,则提供的第一个和第二个参数是个体数据对象的值(例如,d == {key: 'United States', value: 200})和它的索引(i)。

使用回调函数设置条形图的高度和 x 位置(允许 2 像素的填充),并在我们的六个节点选择上调用append将产生图 A-1。

dpj2 aa01

图 A-1. 使用 D3 的enter方法生成一些条形图

我鼓励您通常使用 Chrome(或等效)的元素选项卡来调查您的 D3 生成的 HTML。使用元素选项卡查看我们的小型条形图显示图 A-2。

dpj2 aa02

图 A-2. 使用元素选项卡查看enterappend生成的 HTML

因此,我们已经看到在空选择上调用enter时会发生什么。但是,在具有用户驱动的变化数据集的交互式图表中,当我们已经有一些条形图时会发生什么?

让我们在我们的起始 HTML 中添加一些bar类矩形:

<div id="nobel-bar">
  <svg width="600" height="400">
    <g class="chart" transform="translate(40, 20)">
      <rect class='bar'></rect>
      <rect class='bar'></rect>
    </g>
  </svg>
</div>

如果我们现在执行与之前相同的数据绑定和输入,在我们的选择上调用data,两个占位矩形绑定到我们nobelData数组的前两个成员(即[{key: 'United States', value: 200}, {key: 'United Kingdom', value:80}])。这意味着enter现在仅返回四个占位符,与nobelData数组的最后四个元素关联:

var svg = d3.select('#nobel-bar .chart');

var bars = svg.selectAll('.bar')
    .data(nobelData);

bars = bars.enter(); # return four placeholder nodes

如果现在在输入的bars上调用append,我们将得到图 A-3 中显示的结果,显示最后四个条形图(请注意它们保留了它们的索引i,用于设置它们的 x 位置)。

dpj2 aa03

图 A-3. 在现有条形图上调用enterappend

图 A-4 显示了最后四个条形图生成的 HTML。正如我们将看到的,前两个元素的数据现在绑定到我们添加到初始条形图组的两个虚拟节点上。我们只是还没有使用它来调整那些矩形的属性。使用新数据更新旧条形图是我们即将看到的更新模式的关键元素之一。

dpj2 aa04

图 A-4. 使用元素标签查看由enterappend在部分选择上生成的 HTML

强调一下,理解enterexit(以及remove)对于与 D3 健康进展至关重要。多玩一下,检查你生成的 HTML,输入一些数据,并且通常变得有些混乱,学习其方方面面。在进入 D3 的核心——更新模式——之前,让我们先看一看访问绑定数据。

访问绑定的数据

查看 DOM 变化的好方法是使用浏览器的 HTML 检查器和控制台跟踪 D3 的变化。在 图 A-1 中,我们使用 Chrome 的控制台查看代表第一个条形图的rect元素,在数据绑定之前和使用data方法将nobelData绑定到条形图之后。正如你所看到的,D3 已经向rect元素添加了一个__data__对象,用于存储其绑定的数据——在本例中是我们nobelData列表的第一个成员。__data__对象由 D3 的内部管理使用,其数据基本上是供给更新方法如attr的函数使用的。

让我们看一个使用元素的__data__对象中数据的小例子,设置其name属性。name属性对于制作特定的 D3 选择非常有用。例如,如果用户选择了特定的国家,现在我们可以使用 D3 获取其所有命名组件,并根据需要调整它们的样式。我们将使用在 图 A-5 中绑定数据的条形图,并使用其绑定数据的key属性设置名称:

let bar = d3.select('#nobel-bar .bar');

bar.attr('name', function(d, i){ ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/1.png)

    let sane_key = d.key.replace(/ /g, '_'); ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/2.png)

    console.log('__data__ is: ' + JSON.stringify(d)
    + ', index is ' + i)

    return 'bar__' + sane_key; ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/dtviz-py-js-2e/img/3.png)
    });
// console out: // __data__ is: {"key":"United States","value":336}, index is 0

1

所有 D3 的设置方法都可以将函数作为它们的第二个参数。这个函数接收绑定到选定元素的数据(d)及其在数据数组中的位置(i)。

2

我们使用正则表达式(regex)将键中的所有空格替换为下划线(例如,美国 → 美国 _)。

3

这将把条形图的name属性设置为'bar__ 美国'

图 17-3 中列出的所有设置方法(attrstyletext 等)都可以接受一个函数作为第二个参数,该函数将接收绑定到元素的数据以及元素的数组索引。该函数的返回值用于设置属性的值。正如我们将看到的那样,在绑定新数据并使用这些函数式设置器来适应属性、样式和属性时,交互式可视化将反映出对可视化数据集的更改。

dpj2 aa05

图 A-5. 使用 Chrome 控制台展示使用 D3 的 data 方法绑定数据后添加 __data__ 对象
posted @ 2024-06-17 17:11  绝不原创的飞龙  阅读(9)  评论(0编辑  收藏  举报