SQL-数据分析-全-

SQL 数据分析(全)

原文:zh.annas-archive.org/md5/412388ca08785ad82dcd36a8681a9d87

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

在过去的 20 年中,我大部分工作时间都在使用 SQL 处理数据。在这些年里,我在涵盖广泛的消费者和企业之间行业的科技公司工作过。在此期间,数据量大幅增加,我使用的技术也取得了长足的进步。数据库比以往任何时候都更快,用于传达数据意义的报告和可视化工具也更加强大。然而,有一件事情始终保持非常稳定,那就是 SQL 仍然是我工具箱中的重要组成部分。

我记得第一次学习 SQL 的时候。我开始我的职业生涯是在金融领域,那里主要使用电子表格,我擅长编写公式和记忆所有的键盘快捷键。有一天,我完全陷入其中,Ctrl 和 Alt 同时点击键盘上的每一个键,只是想看看会发生什么(然后为同事们制作了一个备忘单)。那既是乐趣,也是生存:我越快地处理电子表格,我就越有可能在午夜前完成工作,从而可以回家休息睡觉。电子表格的掌握让我在下一份工作中站稳了脚跟,这是一家初创公司,我在那里第一次接触了数据库和 SQL。

我的工作的一部分是在电子表格中处理库存数据,由于早期互联网规模的原因,数据集有时会有数万行。当时对我来说,这就是所谓的“大数据”。我习惯于在电脑的 CPU 在运行 vlookup 魔法时去喝杯咖啡或吃午餐。有一天,我的经理休假了,让我去管理他在笔记本电脑上用 Access 构建的数据仓库。刷新数据涉及一系列步骤:在门户网站上运行 SQL 查询,将结果 csv 文件加载到数据库中,然后刷新电子表格报告。在第一次成功加载后,我开始摸索,尝试理解其工作原理,并缠着工程师们教我如何修改 SQL 查询。

我一直对数据着迷,即使当我觉得可能会改变职业方向时,我也一直在做数据方面的工作。操控数据,回答问题,帮助同事更高效地工作,通过数据集了解企业和世界,这些工作从未停止过给我带来乐趣和挑战。

当我开始使用 SQL 时,学习资源并不多。我找到了一本关于基本语法的书,一夜之间读完,然后大部分时间通过试错来学习。在我学习的那些日子里,我直接查询生产数据库,由于我过于雄心勃勃(或更可能只是写得不好),有几次把网站搞垮了。幸运的是,我的技能逐渐提升,多年来我学会了从数据表中向前推导,从所需的输出向后推导,解决技术和逻辑挑战,并编写能够返回正确数据的查询。最终,我设计并建立了数据仓库,从不同的来源收集数据,避免了瘫痪关键生产数据库的风险。在编写 SQL 查询之前,我学到了很多关于何时以及如何聚合数据,以及何时保留数据在更原始的形式中的经验。

我和其他同一时间接触数据的人交流心得后发现,我们大多数人都是以同样的临时方式学习的。幸运的是,我们中的一些人有同行,可以分享技术。大多数 SQL 文本要么只是介绍性和基础性的(这些绝对有其存在的必要性!),要么针对数据库开发人员。对于专注于分析工作的高级 SQL 用户而言,能够提供的资源很少。知识往往被锁定在个人或小团队中。本书的目标之一是改变这种情况,为从事 SQL 解决常见分析问题的实践者提供参考,并希望激发使用您可能之前没有见过的技术进行数据研究的新探索。

本书中使用的约定

本书中使用以下排版约定:

Italic

表示新术语、网址、电子邮件地址、文件名、文件扩展名和关键字。

Constant width

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

Constant width bold

显示用户应直接输入的命令或其他文本。

Constant width italic

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

Tip

这个元素表示提示或建议。

注意

这个元素表示一般注释。

警告

这个元素表示警告或注意事项。

使用代码示例

补充材料(代码示例、练习等)可从https://github.com/cathytanimura/sql_book下载。

如果您有技术问题或在使用代码示例时遇到问题,请发送电子邮件至bookquestions@oreilly.com

本书旨在帮助您完成工作。一般来说,如果本书提供了示例代码,您可以在程序和文档中使用它。除非您复制了代码的大部分,否则无需联系我们寻求许可。例如,编写一个使用本书中多个代码片段的程序不需要许可。销售或分发 O’Reilly 图书中的示例需要许可。引用本书回答问题并引用示例代码无需许可。将本书中大量示例代码整合到产品文档中需要许可。

我们感谢您的引用,但通常不要求。引用通常包括标题、作者、出版商和 ISBN。例如:“SQL for Data Analysis by Cathy Tanimura (O’Reilly). Copyright 2021 Cathy Tanimura, 978-1-492-08878-3.”

如果您觉得您使用的代码示例超出了合理使用范围或上述授权,请随时通过permissions@oreilly.com联系我们。

O’Reilly 在线学习

注意

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

我们独特的专家和创新者网络通过图书、文章和我们的在线学习平台分享他们的知识和专业知识。O’Reilly 的在线学习平台让您随时访问现场培训课程、深度学习路径、交互式编码环境以及来自 O’Reilly 和其他 200 多个出版商的大量文本和视频。欲了解更多信息,请访问http://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/sql-data-analysis

通过电子邮件bookquestions@oreilly.com进行有关本书的评论或技术问题询问。

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

在 Facebook 上找到我们:http://facebook.com/oreilly

关注我们的 Twitter 账号:http://twitter.com/oreillymedia

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

致谢

这本书的完成离不开 O'Reilly 的许多人的努力。Andy Kwan 邀请我参与这个项目。Amelia Blevins 和 Shira Evans 指导了我整个过程,并提供了有益的反馈意见。Kristen Brown 负责推动书的生产过程。Arthur Johnson 提升了文本的质量和清晰度,并让我对 SQL 关键词有了更深入的思考。

多年来,许多同事在我学习 SQL 的旅程中起到了重要作用,我感激他们的教程、技巧和分享的代码,以及多年来一起头脑风暴解决分析问题的时光。Sharon Lin 让我看到了正则表达式的魅力。Elyse Gordon 给了我许多写书的建议。Dave Hoch 和我们关于实验分析的对话启发了第七章。Star Chamber 的 Dan、Jim 和 Stu 一直是我最喜欢的技术狂人。我也感激多年来提出艰难问题的所有同事们——当这些问题得到回答后,他们又提出了更艰难的问题。

我要感谢我的丈夫 Rick,儿子 Shea,女儿们 Lily 和 Fiona,以及妈妈 Janet,他们给予了我爱和鼓励,最重要的是给了我时间完成这本书。Amy、Halle、Jessi 和 Slack 的这群人在我写作和疫情封锁的几个月里保持了我的理智和欢笑。

第一章:数据分析与 SQL

如果你正在阅读这本书,你可能对数据分析感兴趣,并希望使用 SQL 来完成它。你可能对数据分析有经验,但对 SQL 比较新,或者你可能对 SQL 有经验,但对数据分析比较新。或者你可能对这两个主题都很陌生。无论你的起点如何,本章为本书其余部分涵盖的主题奠定基础,并确保我们有共同的词汇。我将首先讨论什么是数据分析,然后转向 SQL 的讨论:它是什么,为什么如此流行,它与其他工具的比较,以及它如何融入数据分析中。然后,由于现代数据分析与使其成为可能的技术密切相关,我将结束本章讨论您在工作中可能遇到的不同类型的数据库,它们的用途以及这对您编写的 SQL 的意义。

什么是数据分析?

收集和存储数据以进行分析是一项非常人性化的活动。用于跟踪粮食存储、税收和人口的系统可以追溯到数千年前,而统计学的根源可以追溯到数百年前。相关学科,包括统计过程控制、运营研究和控制论,在 20 世纪蓬勃发展。有许多不同的名称用来描述数据分析学科,如商业智能(BI)、分析学、数据科学和决策科学,从业者有各种各样的职称。营销人员、产品经理、业务分析师和其他各种人群也进行数据分析。在本书中,我将使用术语“数据分析师”和“数据科学家”互换地表示使用 SQL 理解数据的人。我将称用于构建报告和仪表板的软件为“BI 工具”。

当代意义上的数据分析得以实现,并且与计算历史交织在一起。研究和商业化的趋势共同塑造了它,并且其中包含了一些重要研究人员和大公司的故事,我们将在 SQL 部分讨论这些内容。数据分析将计算的力量与传统统计学的技术融合在一起。数据分析既是数据发现的一部分,也是数据解释和数据沟通的一部分。数据分析的目的很常见是通过人类甚至是通过自动化逐渐增加的机器来改进决策制定。

健全的方法论至关重要,但分析不仅仅是生成正确的数字。它关乎好奇心、提问以及数字背后的“为什么”。它关乎模式和异常,发现和解释关于企业和人类行为的线索。有时分析是基于特定问题而收集的数据集进行的,如科学研究或在线实验。分析也可以针对因业务活动产生的数据,如公司产品的销售,或出于分析目的生成的数据,比如网站和移动应用的用户交互跟踪。这些数据具有广泛的应用可能性,从故障排除到规划用户界面(UI)改进,但通常以一种需要处理才能得出答案的格式和数量到达。第二章将讨论数据分析准备工作,第八章将讨论所有数据从业者都应熟悉的一些伦理和隐私问题。

数据分析已经触及几乎所有行业:制造业、零售业、金融业、医疗保健、教育以及甚至政府都受其影响。体育队伍自从比利·比恩担任奥克兰运动家队总经理之初便采用数据分析,这在迈克尔·刘易斯的书籍《金球》(Norton)中得到了广泛传播。数据分析应用于市场营销、销售、物流、产品开发、用户体验设计、支持中心、人力资源等领域。技术、应用和计算能力的结合导致了数据工程和数据科学等相关领域的蓬勃发展。

数据分析本质上是基于历史数据进行的,重要的是要记住过去并不一定预测未来。世界在变化,组织也在变化——新产品和流程被引入,竞争对手崛起和衰落,社会政治气候变化。批评声音指责数据分析只是在回顾过去。尽管这种说法有一定道理,但我见过组织通过分析历史数据获得了巨大价值。挖掘历史数据有助于我们理解客户、供应商和流程的特征和行为。历史数据可以帮助我们制定明智的估计和预测结果的范围,有时会错误,但很多时候会正确。过去的数据可以指出缺口、弱点和机会。它让组织优化、节省资金,并降低风险和欺诈。它还可以帮助组织发现机会,并成为让客户满意的新产品的基石。

注意

如今,几乎没有不进行某种形式的数据分析的组织,但仍然有一些顽固的例外。为什么一些组织不使用数据分析?一个论点是成本与价值的比例。收集、处理和分析数据需要工作和一定程度的财务投入。有些组织可能太新,或者过于随意。如果没有一个一致的流程,很难生成足够一致的数据进行分析。最后,还有伦理考量。收集或存储某些人在某些情况下的数据可能受到监管甚至禁止。例如,有关儿童和医疗干预的数据是敏感的,围绕其收集有广泛的法规。即使是其他方面数据驱动的组织也需要注意客户隐私,并深思熟虑应收集哪些数据、为什么需要这些数据以及应该存储多长时间。欧盟的《通用数据保护条例》(GDPR)和加州消费者隐私法案(CCPA)等法规已改变了企业对消费者数据的看法。我们将在第八章中更深入地讨论这些法规。作为数据从业者,我们应始终思考我们工作的伦理意涵。

当我与组织合作时,我喜欢告诉人们,数据分析不是一个在固定日期结束的项目,而是一种生活方式。培养数据驱动的思维是一个过程,收获成果是一段旅程。未知变为已知,困难问题被逐步解答直至找到答案,最关键的信息被嵌入到支持战术和战略决策的仪表板中。有了这些信息,新的更难的问题被提出,然后这个过程重复。

数据分析对于想要开始学习的人来说很容易接触,但要掌握却很困难。技术可以学习,特别是 SQL。许多问题,如优化营销支出或检测欺诈,是熟悉的并且可以跨行业转换。每个组织都是不同的,每个数据集都有其特点,所以即使是熟悉的问题也可能带来新的挑战。传达结果是一种技能。学会提出良好的建议,并成为组织的信任伙伴需要时间。根据我的经验,简单而有说服力的分析比复杂而表达不清的分析更具有影响力。成功的数据分析还需要伙伴关系。你可能有很好的见解,但如果没有人来执行,你并没有真正产生影响。即使拥有所有技术,关键仍然在于人,关系至关重要。

为什么选择 SQL?

本节描述了什么是 SQL,使用它的好处,它与其他常用于分析的语言的比较,以及最后 SQL 如何融入分析工作流程。

什么是 SQL?

SQL 是与数据库通信的语言。这个缩写代表结构化查询语言,可以发音为“sequel”,也可以按每个字母发音,如“ess cue el”。这只是我们将看到的许多关于 SQL 的争议和不一致之一,但无论你如何发音,大多数人都会明白你的意思。关于 SQL 是否是编程语言存在一些争论。它不像 C 或 Python 那样是通用的编程语言。没有数据库和表中的数据,SQL 只是一个文本文件。SQL 不能建立网站,但它在与数据库中的数据工作时非常强大。在实际层面上,最重要的是 SQL 可以帮助你完成数据分析工作。

IBM 是第一个开发 SQL 数据库的公司,使用了 1960 年代 Edgar Codd 发明的关系模型。关系模型是使用关系管理数据的理论描述。通过创建第一个数据库,IBM 帮助推动了这一理论,但它也考虑到了商业因素,正如 Oracle、Microsoft 和其他所有商业化数据库的公司一样。从一开始,计算机理论与商业现实之间存在紧张关系。SQL 在 1987 年成为国际标准化组织(ISO)标准,1986 年成为美国国家标准化组织(ANSI)标准。尽管所有主要数据库在实现 SQL 时都遵循这些标准,但许多数据库具有使用户更轻松的变体和功能。这些变体使得在不进行某些修改的情况下在数据库之间移动 SQL 更加困难。

SQL 用于访问、操作和检索数据库中对象的数据。数据库可以有一个或多个模式,提供组织和结构,并包含其他对象。在模式内,数据分析中最常用的对象是表、视图和函数。表包含字段,用于保存数据。表可以有一个或多个索引;索引是一种特殊的数据结构,允许更有效地检索数据。索引通常由数据库管理员定义。视图本质上是存储的查询,可以像表一样引用。函数允许常用的计算或过程集存储,并在查询中轻松引用。它们通常由数据库管理员或 DBA 创建。图 1-1 概述了数据库的组织。

图 1-1. 数据库组织和数据库对象概述

要 要与数据库通信,SQL 有四种子语言用于处理不同的工作,这些在大多数数据库类型中都是标准的。大多数从事数据分析工作的人不需要每天记住这些子语言的名称,但在与数据库管理员或数据工程师的交流中可能会涉及到,因此我会简要介绍它们。这些命令可以在同一 SQL 语句中流畅地运行,有些可以同时存在于同一个 SQL 语句中。

DQL,或者数据查询语言,是本书的主要内容。它用于查询数据,可以理解为使用代码向数据库提问。DQL 命令包括SELECT,对于之前使用过 SQL 的用户来说应该很熟悉,但根据我的经验,DQL 这个缩写并不经常使用。SQL 查询可以只有一行,也可以跨越多行。SQL 查询可以访问单个表(或视图),可以通过连接从多个表中组合数据,也可以在同一数据库中查询多个模式。SQL 查询通常不能跨数据库查询,但在某些情况下,可以通过巧妙的网络设置或额外的软件从多个来源检索数据,甚至可以是不同类型的数据库。SQL 查询是自包含的,除了表之外,不引用变量或前面步骤中的输出,不像脚本语言那样。

DDL,或者数据定义语言,用于在数据库中创建和修改表、视图、用户和其他对象。它影响结构但不影响内容。有三个常见的命令:CREATE用于创建新对象,ALTER用于更改对象的结构,比如向表中添加列,DROP删除整个对象及其结构。你可能会听到 DBA 和数据工程师谈论与 DDL 相关的工作,这实际上是指用于创建、修改或删除的文件或代码片段。在分析环境中使用 DDL 的例子包括创建临时表的代码。

DCL,或者数据控制语言,用于访问控制。命令包括GRANTREVOKE,分别用于授予权限和撤销权限。在分析环境中,GRANT可能需要用于允许同事查询你创建的表。当有人告诉你数据库中存在一个表但你看不到时,可能需要对你的用户授予权限。

DML,或者数据操作语言,用于操作数据本身。命令包括INSERT用于添加新记录,基本上是 ETL 中的“加载”步骤,UPDATE用于更改字段中的值,DELETE用于删除行。如果你有任何类型的自管理表(如临时表、沙箱表),或者扮演数据库所有者和分析者的角色,你将遇到这些命令。

这四种子语言存在于所有主要的数据库中。在本书中,我主要将专注于 DQL。我们将在第八章中涉及到一些 DDL 和 DML 命令,并且您也会在书籍的 GitHub 网站中看到一些示例,这些示例用于创建和填充示例中使用的数据。由于这些共同的命令集,任何数据库编写的 SQL 代码对于习惯于使用 SQL 的人来说都会很熟悉。然而,从另一个数据库中阅读 SQL 可能会感觉有点像听一个使用与您相同语言但来自其他国家或地区的人说话。语言的基本结构是相同的,但俚语不同,有些词甚至有完全不同的意义。数据库之间 SQL 的变化通常被称为方言,数据库用户会提到 Oracle SQL、MSSQL 或其他方言。

然而,一旦您掌握了 SQL,只要注意到如空值、日期和时间戳的处理、整数的除法以及大小写敏感等细节,您就可以与不同类型的数据库一起工作。

本书以 PostgreSQL 或 Postgres 作为示例,尽管我会尝试指出在其他类型数据库中代码可能会有意义上的不同。您可以在个人计算机上安装Postgres来跟随示例操作。

SQL 的好处

使用 SQL 进行数据分析有很多好处,从计算能力到数据分析工具中的普及性和其灵活性都是如此。

或许使用 SQL 的最佳理由是,世界上大部分的数据已经存储在数据库中。很可能您自己的组织已经有一个或多个数据库。即使数据尚未存储在数据库中,将其加载到数据库中也可能值得,以便利用存储和计算优势,尤其是与电子表格等替代方案相比。近年来,计算能力有了爆炸性增长,数据仓库和数据基础设施已经发展出来以利用这种增长。一些新的云数据库允许在内存中查询大量数据,进一步加快速度。等待查询结果返回几分钟或几小时的时代可能已经结束,尽管分析师可能会因此编写更复杂的查询。

SQL 是与数据库交互和从中检索数据的事实标准。许多流行软件都使用 SQL 连接数据库,从电子表格到商业智能和可视化工具以及编程语言如 Python 和 R(在下一节讨论)。由于可用的计算资源,尽可能在数据库中执行尽可能多的数据操作和聚合通常有下游优势。我们将在第八章深入讨论构建复杂数据集以供下游工具使用的策略。

SQL 的基本构建块可以以无数种方式组合起来。从相对较少的构建块——语法——开始,SQL 就可以完成广泛的任务。SQL 可以进行迭代开发,并且在进行过程中轻松审查结果。它可能不是一个完整的编程语言,但它可以做很多事情,从数据转换到复杂计算再到回答问题。

最后,SQL 相对容易学习,具有有限的语法量。您可以快速学习基本关键字和结构,然后随着时间的推移在处理各种数据集时不断完善自己的技能。考虑到世界上各种数据集和可能提出的问题范围,SQL 的应用几乎是无限的。SQL 在许多大学教授,并且许多人在工作中掌握了一些技能。即使没有 SQL 技能的员工也可以接受培训,学习曲线可能比其他编程语言更容易。这使得将数据存储在关系数据库中进行分析成为组织的合乎逻辑的选择。

SQL 与 R 或 Python 的比较

虽然 SQL 是数据分析中流行的语言,但它并不是唯一的选择。R 和 Python 是用于数据分析的其他流行语言之一。R 是统计和绘图语言,而 Python 是一种通用编程语言,在处理数据方面有优势。两者都是开源的,可以安装在笔记本电脑上,并有活跃的社区开发处理各种数据操作和分析任务的包或扩展。在选择 R 和 Python 之间,超出了本书的范围,但在线上有许多关于它们各自优势的讨论。这里将它们作为 SQL 的编码语言替代品来考虑。

SQL 与其他编程语言的一个主要区别在于代码运行的位置以及因此可用的计算资源。SQL 始终在数据库服务器上运行,利用其所有的计算资源。对于分析,R 和 Python 通常在您的本地机器上运行,因此计算资源受本地可用资源的限制。当在大数据集上执行除最简单分析以外的任何工作时,将工作推向具有更多资源的数据库服务器是一个不错的选择。由于数据库通常设置为持续接收新数据,所以当需要定期更新报告或仪表板时,SQL 也是一个不错的选择。

第二个区别在于数据的存储和组织方式。关系型数据库总是将数据组织成表内的行和列,因此对于每个查询,SQL 都假设这种结构。R 和 Python 有更多种存储数据的方式,包括变量、列表和字典等多种选项。这些提供了更大的灵活性,但学习曲线更陡峭。为了便于数据分析,R 拥有数据框架(data frames),类似于数据库表格,将数据组织成行和列。而 pandas 包则使得数据框架在 Python 中得以使用。即使存在其他选项,表格结构仍然对于分析是宝贵的。

循环是 SQL 与大多数其他计算机编程语言之间的另一个主要区别。循环是一种指令或一组指令,重复执行直到满足指定条件。SQL 聚合隐式地循环处理数据集,无需额外的代码。稍后我们将看到,在数据透视或反透视时,无法循环处理字段可能导致 SQL 语句冗长。虽然深入讨论超出了本书的范围,一些供应商已经为 SQL 创建了扩展,例如 Oracle 的 PL/SQL 和 Microsoft SQL Server 的 T-SQL,这些扩展允许功能,例如循环。

SQL 的一个缺点是你的数据必须在数据库中¹,而 R 和 Python 可以从本地存储的文件导入数据,也可以访问存储在服务器或网站上的文件。这对于许多临时项目是方便的。数据库可以安装在笔记本电脑上,但这确实增加了额外的开销。另一方面,例如 R 的 dbplyr 包和 Python 的 SQLAlchemy 包允许使用这些语言编写的程序连接到数据库,执行 SQL 查询,并在进一步处理步骤中使用结果。在这方面,R 或 Python 可以与 SQL 互补。

R 和 Python 都具有复杂的统计函数,这些函数可以是内置的或者在包中可用。尽管 SQL 有例如计算平均值和标准偏差的函数,但是在实验分析中需要的 p 值和统计显著性的计算(详见第七章)无法仅使用 SQL 完成。除了复杂的统计学外,机器学习是另一个最好使用这些其他编程语言来处理的领域。

在决定是否使用 SQL、R 或 Python 进行分析时,请考虑:

  • 数据位于何处——是在数据库、文件还是网站中?

  • 数据量有多大?

  • 数据将用于何处——报告、可视化、统计分析?

  • 是否需要使用新数据进行更新或刷新?更新频率是多少?

  • 你的团队或组织使用什么,以及符合现有标准有多重要?

关于哪种语言和工具最适合进行数据分析或数据科学存在很多争论。与许多事物一样,通常有多种方法可以完成分析。编程语言在流行度上不断演变和变化,我们很幸运生活和工作在一个有这么多好选择的时代。SQL 已经存在很长时间,可能在未来几年仍然流行。最终目标是使用最适合工作的最佳工具。本书将帮助您充分利用 SQL 进行数据分析,无论您的工具包中还有什么其他工具。

SQL 作为数据分析工作流的一部分

现在我已经解释了 SQL 是什么,讨论了它的一些好处,并将其与其他语言进行了比较,我们将转向讨论 SQL 在数据分析过程中的位置。分析工作总是从一个问题开始,可能是关于新客户数量、销售趋势如何,或者为什么有些用户长时间停留,而其他人试用服务后就不再返回。一旦问题被确定,我们考虑数据的来源、数据存储位置、分析计划以及如何向观众呈现结果。图 1-2 展示了这个过程中的步骤。本书的重点是查询和分析,尽管我会简要讨论其他步骤,以便将查询和分析阶段放入更广泛的背景中。

图 1-2. 数据分析过程中的步骤

首先,数据由源系统生成,这个术语包括任何生成感兴趣数据的人或机器过程。数据可以由人工生成,例如某人填写表格或在医生就诊时记录笔记。数据也可以是机器生成的,例如应用程序数据库记录购买、事件流系统记录网站点击,或者营销管理工具记录电子邮件打开。源系统可以生成多种不同类型和格式的数据,第二章将更详细地讨论它们以及源类型可能如何影响分析。

第二步是将数据移动并存储到数据库进行分析。我将使用术语数据仓库,这是一个将组织中各处的数据整合到一个中央库中的数据库,以及数据存储,它是指可以查询的任何类型的数据存储系统。您可能会遇到的其他术语包括数据集市,它通常是数据仓库的一个子集,或者更专注的数据仓库;以及数据湖,一个术语,它可以意味着数据驻留在文件存储系统中,或者它存储在数据库中,但没有数据仓库中常见的数据转换程度。数据仓库可以从简单小型到庞大昂贵。在本书的示例中,运行在笔记本电脑上的数据库就足够了。重要的是将您需要进行分析的数据集中在一个地方。

注意

通常由个人或团队负责将数据加载到数据仓库中。这个过程称为ETL,即抽取、转换、加载。抽取从源系统中提取数据。转换可选地更改数据结构,执行数据质量清洗或聚合数据。加载将数据放入数据库中。这个过程也可以称为ELT,即抽取、加载、转换——区别在于,在加载数据之后才执行转换,通常使用 SQL。在 ETL 的上下文中,您可能还会听到目标这些术语。源是数据来源,目标是目的地,即数据库及其中的表格。即使使用 SQL 进行转换,也会使用其他语言如 Python 或 Java 来将步骤粘合在一起,协调调度,并在出现问题时发出警报。市场上有许多商业产品以及开源工具可用,因此团队不必完全从头开始创建 ETL 系统。

一旦数据进入数据库,下一步是执行查询和分析。在这一步骤中,应用 SQL 来探索、分析、清洗、整理和分析数据。图 1-3 展示了这个过程的一般流程。探索数据包括熟悉主题、数据生成地点以及存储数据的数据库表格。分析包括检查数据集中的唯一值和记录分布。清洗包括修复不正确或不完整的数据,添加分类和标志,处理空值。整理是将数据排列成结果集中所需的行和列的过程。最后,分析数据涉及审查输出以发现趋势、结论和洞察。虽然这个过程被展示为线性的,但在实践中,它往往是循环的——例如,当整理或分析显示需要清洗的数据时。

图 1-3. 分析工作流程中查询和分析步骤内的阶段

将数据呈现为最终输出形式是整体工作流程的最后一步。商业人士不会希望收到一份 SQL 代码的文件;他们期待你呈现图表、图形和见解。沟通是通过分析产生影响的关键因素,为此我们需要一种与他人共享结果的方式。有时候,你可能需要应用比 SQL 更复杂的统计分析,或者想将数据输入到机器学习(ML)算法中。幸运的是,大多数报告和可视化工具都有 SQL 连接器,可以让你从整个表或预先编写的 SQL 查询中提取数据。通常用于 ML 的统计软件和语言也通常具有 SQL 连接器。

分析工作流程包括多个步骤,并且通常涉及多种工具和技术。SQL 查询和分析是许多分析的核心,也是我们将在接下来的章节中重点讨论的内容。第二章将讨论源系统的类型及其生成的数据类型。本章的其余部分将介绍你在分析过程中可能会遇到的数据库类型。

数据库类型及其处理方式

如果你在使用 SQL,那么你就在处理数据库。数据库类型各不相同——从开源到专有,从行存储到列存储。有本地数据库和云数据库,还有混合数据库,组织在云供应商基础设施上运行数据库软件。还有许多根本不是数据库但可以用 SQL 查询的数据存储。

不同类型的数据库各有千秋;在进行分析工作时,每种数据库类型都有其优势和劣势。与分析工作流程的其他部分使用的工具不同,你可能对组织中使用的数据库技术没有太多发言权。了解你所使用的数据库的细节将帮助你更高效地工作,并利用它提供的任何特殊 SQL 函数。熟悉其他类型的数据库将有助于你在进行构建或迁移数据仓库的项目时使用。你可能希望在个人、小规模项目中在笔记本电脑上安装数据库,或者出于类似原因获取云数据仓库的实例。

自引入以来,数据库和数据存储一直是技术发展的一个动态领域。自 21 世纪初以来,几项趋势驱动了技术的发展,这些趋势对今天的数据从业者来说真是令人兴奋。首先,随着互联网、移动设备和物联网(IoT)的发展,数据量急剧增加。2020 年,IDC 预测,到 2025 年,全球存储的数据量将增长到 175ZB。这种数据规模难以想象,并且并非所有数据都将存储在用于分析的数据库中。如今,公司拥有的数据规模达到了 TB 和 PB 级,这在 20 世纪 90 年代及更早时期的技术下是不可能处理的。其次,数据存储和计算成本的降低,以及云计算的出现,使组织更便宜、更容易地收集和存储这些海量数据。计算机内存价格下降,意味着大量数据可以加载到内存中,执行计算,并返回结果,而无需读写磁盘,大大提高了速度。第三,分布式计算使得能够将工作负载分散到许多机器上。这使得大量可调整的计算能力能够用于复杂的数据任务中。

数据库和数据存储以多种不同方式结合这些技术趋势,以优化特定类型的任务。对于分析工作,有两种广泛的数据库类别是相关的:行存储和列存储。在下一节中,我将介绍它们,讨论它们的相似性和不同之处,以及在这些数据库中存储数据时的分析意义。最后,我将介绍一些超出数据库的其他类型的数据基础设施,您可能会遇到。

行存储数据库

行存储数据库,也称为事务性数据库,旨在高效处理事务:插入更新删除。流行的开源行存储数据库包括 MySQL 和 Postgres。在商业领域,Microsoft SQL Server、Oracle 和 Teradata 被广泛使用。虽然它们并非专为分析而优化,但多年来,行存储数据库是建立数据仓库的唯一选择。通过精心调整和模式设计,这些数据库可以用于分析。由于开源选项成本低廉,并且熟悉维护的数据库管理员,它们也很有吸引力。许多组织在数据基础设施建设的第一步中,将生产数据库复制到相同的技术中。因此,数据分析师和数据科学家在职业生涯中可能会与行存储数据库中的数据打交道。

我们通常将表格看作行和列,但是数据必须进行序列化以便存储。查询会在硬盘上搜索所需数据。硬盘被组织成一系列固定大小的块。扫描硬盘既耗时又耗资源,因此尽量减少需要扫描的磁盘量以返回查询结果至关重要。行存储数据库通过将数据按行序列化来解决这个问题。图 1-4 展示了行存储数据的示例。在查询时,整行数据被读入内存。这种方法在进行行级更新时速度快,但如果需要跨多行进行计算且只需要少数列,则速度较慢。

图 1-4. 行存储,即将每行数据一起存储在磁盘上

为了减少表格的宽度,行存储数据库通常按照第三范式进行建模,这是一种数据库设计方法,旨在仅存储每个信息片段一次,以避免重复和不一致性。这对事务处理非常高效,但通常会导致数据库中存在大量表格,每个表格只有少数列。分析这样的数据通常需要进行多次连接,非开发人员可能很难理解所有表格之间的关系以及特定数据存储在哪里。在进行分析时,通常的目标是反范式化,或者将所有数据聚集在一个地方。

表格通常有一个主键来确保唯一性——换句话说,它防止数据库为相同内容创建多条记录。表格通常会有一个id列,它是一个自增整数,每次插入新记录时都会获得上次插入记录之后的下一个整数,或者是由主键生成器创建的字母数字值。还应该有一组列,这些列一起使得行数据唯一;这些字段的组合被称为复合键,有时也称为业务键。例如,在一个人员表中,first_namelast_namebirthdate一起可能使得行数据唯一。Social_security_id也可以是唯一标识符,除了表的person_id列。

表还可以选择性地添加索引,以加快查找特定记录和涉及这些列的连接速度。索引将字段的值以单个数据片段的形式存储,同时存储行指针。由于索引比整个表小,因此扫描速度更快。通常会对主键建立索引,但也可以对其他字段或字段组建立索引。在使用行存储数据库时,了解所使用的表中哪些字段有索引非常有用。通过添加索引可以加快常见连接操作的速度,因此值得调查分析查询是否运行时间过长。索引并非免费:它们占用存储空间,并减慢加载速度,因为每次插入新值都需要更新。数据库管理员可能不会为所有可能有助于分析的内容建立索引。除了报告外,分析工作可能不足以优化索引。在解决问题的新方法时,通常会放弃一种方法。

星型模式建模部分是为了使行存储数据库更适合分析工作负载而开发的。其基础在《数据仓库工具包》一书中详细阐述,推崇将数据建模为一系列事实表和维度表。事实表代表事件,如零售店交易。维度包含描述符,如客户姓名和产品类型。由于数据并不总是完全适合事实和维度的分类,因此有一个名为snowflake 模式的扩展,其中一些维度具有自己的维度。

列存储数据库

列存储数据库在 21 世纪初蓬勃发展,尽管它们的理论历史可以追溯到行存储数据库的历史。列存储数据库将一列的值存储在一起,而不是将一行的值存储在一起。这种设计优化了读取多条记录但不一定读取所有列的查询。流行的列存储数据库包括 Amazon Redshift、Snowflake 和 Vertica。

由于压缩技术,列存储数据库在存储大容量数据方面非常高效。缺失值和重复值可以用非常小的标记值表示,而不是完整的值。例如,列存储数据库不会将“United Kingdom”存储成成千上万次,而是存储一个占用极小空间的代理值,同时存储完整的“United Kingdom”值的查找。列存储数据库还通过利用排序数据中值的重复性来压缩数据。例如,数据库可以存储“United Kingdom”标记值重复 100 次的事实,这比重复存储该标记 100 次还要节省空间。

列存储数据库不强制执行主键,也没有索引。重复值由于压缩而不成问题。因此,模式可以针对分析查询进行定制,所有数据都在一个地方,而不是分布在多个需要联接的表中。但是,没有主键可能会导致重复数据轻易混入,因此了解数据来源和质量检查非常重要。

大多数列存储数据库中更新和删除操作成本很高,因为单个行的数据是分布式存储而不是存储在一起的。对于非常大的表,可能存在只写策略,因此我们还需要了解一些关于数据生成的信息,以便确定要使用的记录。数据读取速度也可能较慢,因为在应用计算之前需要对其进行解压缩。

列存储数据库通常是快速分析工作的黄金标准。它们使用标准的 SQL(带有一些特定供应商的变化),在写查询时在很多方面与行存储数据库的工作没有什么不同。数据的大小很重要,以及为数据库分配的计算和存储资源。我见过在几秒钟内跨百万甚至十亿条记录运行的聚合操作。这对生产力大有裨益。

提示

有一些技巧需要注意。由于某些类型的压缩依赖于排序,了解表上进行排序的字段,并将它们用于过滤查询,可以提高性能。如果两个表都很大,则联接表可能很慢。

总而言之,某些数据库可能更容易或更快速地进行操作,但数据库类型本身不会阻止您执行本书中的任何分析。与所有工具一样,使用适合数据量和任务复杂性的工具将使您能够专注于创建有意义的分析。

其他类型的数据基础设施

数据库并非存储数据的唯一方式,现在有越来越多的选择,可以存储分析和支持应用程序所需的数据。文件存储系统,有时被称为数据湖,可能是数据库仓库的主要替代方案。NoSQL 数据库和基于搜索的数据存储是提供低延迟应用程序开发和搜索日志文件的替代数据存储系统。虽然它们通常不是分析过程的一部分,但它们越来越成为组织数据基础设施的一部分,因此我也会在本节简要介绍它们。一个有趣的趋势值得指出的是,尽管这些新类型的基础设施最初旨在摆脱 SQL 数据库的限制,但许多都最终实现了某种 SQL 接口来查询数据。

Hadoop,也称为 HDFS(“Hadoop 分布式文件系统”),是一个利用数据存储和计算能力成本不断下降以及分布式系统的开源文件存储系统。文件被分割成块,并且 Hadoop 将它们分布在存储在集群中的节点或计算机的文件系统上。运行操作的代码被发送到节点,它们并行处理数据。Hadoop 的一个重大突破是允许大量数据以低廉的价格存储。许多大型互联网公司发现,与传统数据库的成本和存储限制相比,这是一个优势,特别是面对大量通常是非结构化数据的情况。Hadoop 的早期版本有两个主要缺点:需要专门的编码技能来检索和处理数据,因为它与 SQL 不兼容,并且程序的执行时间通常相当长。Hadoop 已经成熟,开发了各种工具,允许对数据进行 SQL 或类 SQL 的访问,并加快查询时间。

过去几年中,还推出了其他商业和开源产品,利用廉价的数据存储和快速的内存数据处理,同时提供 SQL 查询功能。其中一些甚至允许分析人员编写单个查询,从多个底层源返回数据。这对于处理大量数据的任何人都是令人兴奋的,并且证明了 SQL 的重要性。

NoSQL 是一种允许非严格关系型数据建模的技术。它允许非常低延迟的存储和检索,这在许多在线应用中至关重要。该类别包括键值对存储和图数据库,后者以节点-边格式存储,以及文档存储。你可能在组织中听说过的这些数据存储的例子有 Cassandra、Couchbase、DynamoDB、Memcached、Giraph 和 Neo4j。早期,NoSQL 被宣传为使 SQL 过时,但最近该首字母缩略词被市场宣传为“不仅仅是 SQL”。为了分析目的,通常需要将存储在 NoSQL 键值存储中的数据移动到更传统的 SQL 数据仓库中,因为 NoSQL 并不优化于一次查询多条记录。图数据库有网络分析等应用,分析工作可以直接在其中使用特定的查询语言进行。然而,工具景观一直在不断演变,也许将来我们能够用 SQL 分析这些数据。

基于搜索的数据存储包括 Elasticsearch 和 Splunk。Elasticsearch 和 Splunk 通常用于分析机器生成的数据,例如日志。这些以及类似的技术具有非 SQL 查询语言,但如果您了解 SQL,通常也能理解它们。鉴于 SQL 技能的普遍性,一些数据存储,如 Elasticsearch,已添加了 SQL 查询接口。这些工具在它们设计的用例中非常有用且功能强大,但通常不适合本书涵盖的分析任务类型。多年来我向人们解释过,它们非常适合在大海中寻找针,但并不太适合测量大海本身。

无论是哪种类型的数据库或其他数据存储技术,趋势是明确的:即使数据量增长并且用例变得更加复杂,SQL 仍然是访问数据的标准工具。其庞大的现有用户群、易学习曲线和强大的分析任务能力意味着,即使是试图摆脱 SQL 的技术也会回归并适应它。

结论

数据分析是一个令人兴奋的学科,对于企业和其他组织有多种应用。SQL 在处理数据方面有许多优点,特别是对于任何存储在数据库中的数据。查询和分析数据是更大分析工作流的一部分,数据科学家可能预期使用多种数据存储类型。既然我们已经奠定了分析、SQL 和数据存储的基础,本书的其余部分将深入探讨如何使用 SQL 进行分析。第二章重点介绍了数据准备,从数据类型的介绍开始,然后转向分析数据的配置、清理和塑形。第 3 至第七章介绍了数据分析的应用,重点关注时间序列分析、队列分析、文本分析、异常检测和实验分析。第八章介绍了在其他工具中进行进一步分析的复杂数据集开发技术。最后,第九章总结了如何将不同类型的分析结合起来获得新的见解,并列出了一些额外资源来支持您的分析旅程。

¹ 有一些较新的技术允许对非关系型数据源中存储的数据进行 SQL 查询。

² Ralph Kimball 和 Margy Ross,数据仓库工具包,第 3 版(印第安纳波利斯:Wiley,2013 年)。

第二章:准备数据进行分析

估计数据科学家花在数据准备上的时间有所不同,但可以肯定的是,这一步骤占据了与数据工作的大部分时间。2014 年,《纽约时报》报道称,数据科学家将 50%至 80%的时间用于清理和整理数据。CrowdFlower 在 2016 年的调查发现,数据科学家将 60%的时间用于清理和组织数据,以便为分析或建模工作做准备。准备数据是如此普遍的任务,以至于出现了用于描述它的术语,如数据 munging、数据 wrangling 和数据准备。(“Mung”是 Mash Until No Good 的首字母缩写,我偶尔也会这样做。)所有这些数据准备工作只是毫无意义的苦工,还是过程中的一个重要部分呢?

当数据集有一个数据字典时,数据准备就更容易了,这是一个有关字段、可能的值、数据收集方式以及与其他数据关系的清晰描述的文档或存储库。不幸的是,这种情况经常并非如此。即使是认识到其价值的人,通常也不会将文档编制排在首位,或者随着添加新字段和表格或数据填充方式的变化,文档会变得过时。数据剖析生成了数据字典的许多元素,因此,如果你的组织已经有了数据字典,现在是使用它并为其做贡献的好时机。如果当前尚无数据字典存在,请考虑开始创建一个!这是你可以为团队和未来的自己提供的最有价值的礼物之一。一个最新的数据字典能够通过基于已完成的剖析工作而不是重复进行来加速数据剖析过程。它还将提高你分析结果的质量,因为你可以验证是否正确使用了字段并应用了适当的过滤器。

即使有数据字典存在,作为分析的一部分,你可能仍然需要进行数据准备工作。在本章中,我将从你可能遇到的数据类型的回顾开始。接下来是 SQL 查询结构的回顾。然后,我将讨论数据剖析作为了解其内容并检查数据质量的一种方式。接下来我会谈一些数据塑形技术,以返回需要进一步分析的列和行。最后,我将介绍一些清理数据的有用工具,以解决任何质量问题。

数据类型

数据是分析的基础,所有数据都有数据库数据类型,并且还属于一个或多个数据类别。对数据可能采取的多种形式有坚实的理解将帮助你成为一名更有效的数据分析师。我将从分析中最常见的数据库数据类型开始。然后我将转向一些概念性分组,这些分组有助于我们理解数据的来源、质量和可能的应用。

数据库数据类型

数据库表中的字段都有定义的数据类型。大多数数据库都有关于它们支持的类型的良好文档,这是获取本文档之外所需细节的良好资源。要成为一名优秀的分析师,您并不一定需要成为数据类型细微差别的专家,但在本书的后面,我们会遇到需要考虑数据类型的情况,因此本节将介绍基础知识。主要的数据类型包括字符串、数值、逻辑和日期时间,如表 2-1 所总结的。这些基于 Postgres,但在大多数主要数据库类型中是类似的。

表 2-1. 常见数据库数据类型总结

类型 名称 描述
String CHAR / VARCHAR 存储字符串。CHAR 是固定长度的,而 VARCHAR 是可变长度的,最大长度为某个值(例如 256 个字符)。
TEXT / BLOB 存储不适合在 VARCHAR 中的较长字符串。调查对象输入的描述或自由文本可能存储在这些字段中。
Numeric INT / SMALLINT / BIGINT 存储整数(整数)。一些数据库支持 SMALLINT 和/或 BIGINT。当字段只需存储位数较少的值时可以使用 SMALLINT,它比普通的 INT 占用更少的内存。BIGINT 可以存储比 INT 更多位数的数字,但占用的空间比 INT 大。
FLOAT / DOUBLE / DECIMAL 存储十进制数,有时可以指定小数位数。
Logical BOOLEAN 存储 TRUE 或 FALSE 值。
DATETIME / TIMESTAMP 存储带有时间的日期。通常以 YYYY-MM-DD hh:mi:ss 格式表示,其中 YYYY 是四位数年份,MM 是两位数月份,DD 是两位数日期,hh 是两位数小时(通常为 24 小时制,取值范围为 0 到 23),mi 是两位数分钟,ss 是两位数秒。一些数据库只存储没有时区的时间戳,而其他一些数据库有专门的类型用于带和不带时区的时间戳。
TIME 存储时间。

字符串数据类型最为灵活。这些可以包含字母、数字和特殊字符,包括制表符和换行符等不可打印字符。字符串字段可以定义为固定或可变长度的字符数。例如,CHAR 字段可以定义为仅允许两个字符以保存美国州名缩写,而存储州全名的字段需要是 VARCHAR 以允许可变长度的字符。字段可以定义为 TEXT、CLOB(字符大对象)或 BLOB(二进制大对象,可以包括附加数据类型如图像),具体取决于数据库以存储非常长的字符串,尽管由于它们通常占用大量空间,这些数据类型往往被节俭使用。当数据加载时,如果到达的字符串超过了定义的数据类型,它们可能会被截断或完全拒绝。SQL 有许多字符串函数,我们将用于各种分析目的。

数字数据类型是存储数字的所有类型,包括正数和负数。数学函数和运算符可以应用于数字字段。数字数据类型包括 INT 类型以及允许小数点的 FLOAT、DOUBLE 和 DECIMAL 类型。整数数据类型通常被实现,因为它们比它们的小数对应类型使用更少的内存。在某些数据库中,如 Postgres,在整数除法时,结果是一个整数,而不是带有小数位的值,这可能与您的期望不同。我们将在本章后面讨论转换数字数据类型以获得正确结果。

逻辑数据类型称为 BOOLEAN。它具有 TRUE 和 FALSE 的值,并且是存储适当情况下的信息的有效方式。比较两个字段的操作将返回一个 BOOLEAN 值作为结果。这种数据类型通常用于创建标志,即总结数据中属性存在或不存在的字段。例如,存储电子邮件数据的表可能有一个 BOOLEAN has_opened字段。

日期时间类型包括 DATE、TIMESTAMP 和 TIME。尽可能将日期和时间数据存储在这些数据库类型的字段中,因为 SQL 有许多有用的函数可以对它们进行操作。时间戳和日期在数据库中非常常见,并且对许多类型的分析至关重要,特别是时间序列分析(见第三章)和队列分析(见第四章)。第三章将讨论日期和时间格式化、转换和计算。

其他数据类型,如 JSON 和地理类型,一些数据库支持,而一些不支持。我在这里不会详细介绍它们,因为它们通常超出本书的范围。但它们显示了 SQL 继续发展以应对新兴分析任务的迹象。

除了数据库数据类型之外,还有许多概念上对数据进行分类的方式。这些分类方式可以影响数据的存储方式以及我们对其进行分析的方式。接下来我将讨论这些分类数据类型。

结构化与非结构化

数据通常被描述为结构化数据、非结构化数据或有时为半结构化数据。大多数数据库设计用来处理结构化数据,其中每个属性存储在一个列中,并且每个实体的实例表示为行。首先创建数据模型,然后根据该数据模型插入数据。例如,地址表可以有街道地址、城市、州和邮政编码字段。每一行将保存特定客户的地址。每个字段都有数据类型,并且只允许输入该类型的数据。当结构化数据插入表中时,将验证每个字段以确保其符合正确的数据类型。结构化数据易于使用 SQL 进行查询。

非结构化数据与结构化数据相反。它没有预先确定的结构、数据模型或数据类型。非结构化数据通常是那些不属于数据库数据的“其他所有内容”。文件、电子邮件和网页都属于非结构化数据。照片、图像、视频和音频文件也是非结构化数据的例子。它们不符合传统的数据类型,因此对于关系型数据库来说更难以高效存储和使用 SQL 进行查询。因此,非结构化数据通常存储在关系型数据库之外。这样可以快速加载数据,但由于缺乏数据验证,可能导致数据质量不佳。正如我们在第一章中看到的,技术不断发展,正在开发新工具以允许 SQL 查询多种类型的非结构化数据。

半结构化数据介于这两种类别之间。许多“非结构化”数据具有一定的结构,我们可以利用它。例如,电子邮件具有发件人和收件人电子邮件地址、主题行、正文文本和发送时间戳,这些可以与这些字段分开存储在数据模型中。元数据,或者关于数据的数据,可以从其他文件类型中提取并存储以进行分析。例如,音乐音频文件可能带有艺术家、歌曲名称、流派和时长的标签。通常情况下,半结构化数据的结构化部分可以使用 SQL 进行查询,SQL 通常也可用于解析或提取结构化数据以进一步查询。我们将在第五章中讨论文本分析时看到这些应用。

定量数据与定性数据

定量数据是数值型的。它测量人、事物和事件。定量数据可以包括描述符,例如客户信息、产品类型或设备配置,同时也包括数值信息,例如价格、数量或访问持续时间。数据可以应用计数、求和、平均数或其他数值函数。定量数据今天通常是由机器生成的,但并非必须如此。在纸质患者接待表上记录的身高、体重和血压是定量数据,教师在电子表格中输入的学生测验分数也是定量数据。

定性数据通常是基于文本的,包括意见、感觉和非严格定量的描述。温度和湿度水平是定量数据,而像“炎热潮湿”这样的描述则是定性的。客户为产品支付的价格是定量的,他们喜欢还是不喜欢它则是定性的。调查反馈、客户支持询问和社交媒体帖子都属于定性数据。有整个专业处理定性数据。在数据分析的背景下,我们通常尝试量化定性数据。其中一种技术是提取关键词或短语并计算它们的出现次数。我们将在第五章更详细地探讨这一点时,会看到更多的技术。另一种技术是情感分析,通过语言结构解释使用的词语含义,除了它们的频率。句子或其他文本主体可以评分其积极或消极的程度,然后使用计数或平均数来得出可能难以总结的见解。自然语言处理领域已经取得了令人兴奋的进展,尽管大部分工作是通过 Python 等工具完成的。

第一方、第二方和第三方数据

第一方数据由组织自行收集。这可以通过服务器日志、记录交易和客户信息的数据库或其他由组织建立和控制的系统完成,并生成有助于分析的数据。由于这些系统是内部创建的,通常可以找到建造它们的人,并了解数据生成的方式。数据分析师还可能能够影响或控制某些数据的创建和存储方式,尤其是在数据质量差的时候由于错误导致的情况下。

第二方数据来自为组织提供服务或代表其进行业务功能的供应商。这些通常是软件即服务(SaaS)产品;常见示例包括 CRM、电子邮件和营销自动化工具、电子商务支持软件以及网页和移动互动追踪器。这些数据与第一方数据类似,因为它们涉及组织本身,由其员工和客户创建。然而,生成和存储数据的代码以及数据模型都由外部控制,数据分析师通常对这些方面影响有限。第二方数据越来越多地被导入组织的数据仓库进行分析。可以通过自定义代码或 ETL 连接器完成此操作,或者使用提供数据集成的 SaaS 供应商。

小贴士

许多 SaaS 供应商提供一些报告功能,因此可能会出现是否值得将数据复制到数据仓库的问题。与工具交互的部门可能会发现报告足够,例如客户服务部门从其帮助台软件中报告解决问题的时间和代理人的生产力。另一方面,客户服务互动可能是客户保留模型的重要输入,这需要将这些数据与销售和取消数据整合到数据存储中。在决定是否从特定数据源导入数据时,有一个很好的经验法则:如果将数据与其他系统的数据组合能够创造价值,那就导入它;如果不能,那么在有更强的案例之前再等待进行这项工作。

第三方数据可以从政府等免费来源购买或获取。除非数据是专门为组织收集的,数据团队通常无法控制格式、频率和数据质量。这些数据通常缺乏第一方和第二方数据的细粒度。例如,大多数第三方来源没有用户级数据,而是可能在邮政编码或城市级别或更高级别与第一方数据合并。然而,第三方数据可能包含独特和有用的信息,例如聚合消费模式、人口统计数据和市场趋势,否则这些信息收集可能非常昂贵或不可能实现。

稀疏数据

稀疏数据 是指在较大集合中存在少量信息的情况。稀疏数据可能会表现为某一列中许多空值和少量实际值。空值不同于值为 0,是数据的 缺失,这将在数据清洗部分后面详细讨论。稀疏数据可能发生在事件稀缺的情况下,例如软件错误或产品目录长尾中的产品购买。它也可能发生在功能或产品推出的早期阶段,只有测试人员或测试客户可以访问时。JSON 是一种处理稀疏数据的方法,从编写和存储的角度来看,它只存储存在的数据并省略其余部分。这与行存储数据库形成对比,后者即使没有值也必须为字段保留内存空间。

对于分析而言,稀疏数据可能会带来问题。当事件稀疏时,趋势未必有意义,相关性也难以与偶然波动区分开来。值得分析数据,正如本章后面讨论的那样,以了解数据是否稀疏以及稀疏数据的位置。一些选项包括将不经常发生的事件或项目分组到更常见的类别中,完全排除稀疏数据或时间段的分析,或显示描述性统计信息以及警示说明,说明这些趋势未必有意义。

存在多种不同类型的数据及其描述方式,其中许多方式是重叠的或不是互斥的。熟悉这些类型不仅有助于编写良好的 SQL,还有助于决定如何以适当的方式分析数据。您可能并不总是能预先知道数据类型,这就是为什么数据分析如此关键的原因。在我们进入具体内容和我们的第一个代码示例之前,我将简要回顾 SQL 查询结构。

SQL 查询结构

SQL 查询具有常见的子句和语法,尽管可以以几乎无限的方式组合这些子句以实现分析目标。本书假定您具有一定的 SQL 知识,但我将在此回顾基础知识,以便我们在接下来的代码示例中有一个共同的基础。

SELECT 子句确定查询将返回的列。每个 SELECT 子句中的表达式将返回一列,表达式之间用逗号分隔。表达式可以是来自表的字段,如 sum 这样的聚合,或者任何数量的计算,如 CASE 语句、类型转换和本章后面以及整本书中将讨论的各种函数。

FROM 子句确定 SELECT 子句中的表达式是从哪些表派生的。一个“表”可以是数据库表,视图(一种保存的查询,否则像表一样工作),或者子查询。子查询本身是一个用括号括起来的查询,其结果被引用它的查询视为任何其他表。一个查询可以在 FROM 子句中引用多个表,尽管它们必须使用 JOIN 类型之一以及指定表之间关系的条件。JOIN 条件通常指定每个表中字段之间的相等性,例如 orders.customer_id = customers.customer_idJOIN 条件可以包括多个字段,并且还可以指定不等式或值的范围,例如日期范围。我们将在本书中看到一系列实现特定分析目标的 JOIN 条件。INNER JOIN 返回两个表中都匹配的所有记录。LEFT JOIN 返回第一个表的所有记录,但只返回第二个表中匹配的记录。RIGHT JOIN 返回第二个表的所有记录,但只返回第一个表中匹配的记录。FULL OUTER JOIN 返回两个表的所有记录。当第一个表中的每条记录与第二个表中的多条记录匹配时,可以产生笛卡尔 JOIN。通常应避免笛卡尔 JOIN,尽管有一些特定的用例,比如生成填充时间序列的数据时会故意使用它们。最后,FROM 子句中的表可以被别名化,或者给定一个或多个字母的较短名称,可以在查询的其他子句中引用。别名可以使查询编写人员免于重复输入长表名,并且使查询更易于阅读。

提示

尽管可以在同一查询中使用 LEFT JOINRIGHT JOIN,但当您坚持使用其中一种时,跟踪逻辑要容易得多。实际上,LEFT JOINRIGHT JOIN 更常用。

WHERE 子句指定需要从结果集中排除或删除行的限制或过滤器。WHERE 是可选的。

SELECT 子句包含聚合函数并且至少有一个非聚合字段时,GROUP BY 子句是必需的。记住应该放在 GROUP BY 子句中的内容的一个简单方法是,它应该包括所有不是聚合的字段。在大多数数据库中,有两种列出 GROUP BY 字段的方式:按字段名称或按位置,如 1、2、3 等。一些人更喜欢使用字段名称表示法,SQL Server 要求使用这种表示法。我更喜欢位置表示法,特别是当 GROUP BY 字段包含复杂表达式或者当我进行大量迭代时。本书通常会使用位置表示法。

这涵盖了 SQL 查询结构的基础。第八章将详细讨论这些子句的每一个,以及在本书中较少见但出现的一些额外子句的顺序评估。既然我们有了这个基础,我们可以转向分析过程中最重要的部分之一:数据剖析。

剖析:分布

数据剖析是我开始处理任何新数据集时首先做的事情。我查看数据是如何安排成模式和表的。我查看表名以熟悉所涵盖的主题,例如客户、订单或访问。我检查几个表的列名,并开始构建表之间关系的心理模型。例如,表可能包括一个通过order_idorder表相关的行项目分解的order_detail表,而order表则通过customer_idcustomer表相关。如果有数据字典,我会审查并与样本行中看到的数据进行比较。

表通常代表组织的操作或某些操作的子集,因此我考虑涵盖的领域或领域,例如电子商务、营销或产品互动。当我们了解数据生成方式时,处理数据会更容易。剖析可以提供关于这一点的线索,或者关于向来源、组织内外负责收集或生成数据的人提出的问题。即使你自己收集数据,剖析也是有用的。

另一个我检查的细节是如何表示历史数据,如果有的话。例如,复制生产数据库的数据集可能不包含客户地址或订单状态的先前值,而一个构建良好的数据仓库可能会有每日变化数据字段的快照。

数据剖析与探索性数据分析或 EDA 的概念相关,由 John Tukey 命名。在他的同名书籍中,¹ Tukey 描述了如何通过计算各种摘要和可视化结果来分析数据集。他包括了查看数据分布的技术,包括茎叶图、箱线图和直方图。

在检查几个数据样本后,我开始查看分布。分布可以帮助我了解数据中存在的值的范围及其出现频率,是否存在空值,以及负值是否与正值并存。分布可以针对连续或分类数据创建,并称为频率。在本节中,我们将看看如何创建直方图,如何使用分箱来理解连续值的分布,以及如何使用 n-分位数来更精确地了解分布情况。

柱状图和频率

了解数据集以及其中特定字段的最佳方法之一是检查每个字段中值的频率。无论您是否怀疑某些值是否可能,或者是否发现意外值并想知道它们出现的频率,频率检查都非常有用。频率查询可以对任何数据类型执行,包括字符串、数字、日期和布尔值。频率查询还是检测稀疏数据的一种有效方式。

查询很简单。可以用count(*)找到行数,并在GROUP BY中列出要分析的字段。例如,我们可以检查虚构的fruit_inventory表中每种类型的水果的频率:

SELECT fruit, count(*) as quantity
FROM fruit_inventory
GROUP BY 1
;
提示

在使用count时,考虑一下数据集中是否可能存在重复记录是值得的。当您想要记录数时可以使用count(*),但是使用count distinct可以找出有多少个唯一项。

频率图 是一种可视化数据集中某些事物出现次数的方法。通常在 x 轴上绘制被分析的字段,y 轴上显示观察次数。图 2-1 展示了我们查询的水果频率图的示例。频率图也可以横向绘制,这样可以很好地容纳较长的值名称。请注意,这是无序的分类数据。

图 2-1. 水果库存的频率图

直方图 是一种可视化数据集中数值分布的方法,对于具有统计学背景的人来说应该很熟悉。一个基本的直方图可以展示一组客户的年龄分布。假设我们有一个包含姓名、注册日期、年龄和其他属性的customers表。要按年龄创建直方图,通过数值字段age进行GROUP BY,并计算count customer_id

SELECT age, count(customer_id) as customers
FROM customers
GROUP BY 1 
;

我们假设的年龄分布结果在图 2-2 中显示。

图 2-2. 按年龄分布的客户

我反复使用的另一种技巧,也成为我最喜欢的面试问题之一的基础,涉及到聚合后跟随的频率计数。我给候选人一个名为orders的假设表,其中包含日期、客户标识符、订单标识符和金额,然后要求他们编写一个 SQL 查询,返回每个客户的订单分布。这不能用简单的查询解决;它需要一个中间聚合步骤,可以通过子查询完成。首先,在子查询中对每个customer_id的订单数进行count。外部查询使用orders数作为类别,并计算客户的数量:

SELECT orders, count(*) as num_customers
FROM
(
    SELECT customer_id, count(order_id) as orders
    FROM orders
    GROUP BY 1
) a
GROUP BY 1
;

这种类型的分析可以应用于需要查看数据中特定实体或属性出现频率的任何情况。在这些示例中,已使用count,但也可以使用其他基本聚合函数(sumavgminmax)来创建直方图。例如,我们可能想通过他们所有订单的sum,他们的avg订单大小,他们的min订单日期或他们的max(最近)订单日期来对客户进行分析。

分箱

当处理连续值时,分箱非常有用。不是计算每个值的观察次数或记录数,而是将数值范围分组在一起,这些分组称为。然后计算落入每个区间的记录数。箱可以具有可变大小或固定大小,这取决于您的目标是将数据分组为对组织具有特定含义的箱,还是大致相等宽度的箱,或包含大致相等记录数的箱。可以使用 CASE 语句、四舍五入和对数函数创建箱。

CASE 语句允许评估条件逻辑。这些语句非常灵活,我们将在整本书中不断使用它们,应用于数据分析、清理、文本分析等领域。CASE 语句的基本结构是:

case when condition1 then return_value_1
     when condition2 then return_value_2
     ...
     else return_value_default
     end

WHEN 条件可以是相等性、不等式或其他逻辑条件。THEN 返回值可以是常量、表达式或表中的字段。可以包括任意数量的条件,但语句将在条件第一次评估为 TRUE 时停止执行并返回结果。ELSE 告诉数据库如果找不到匹配项应使用什么作为默认值,也可以是常量或字段。ELSE 是可选的,如果不包括,则任何非匹配项将返回 null。CASE 语句还可以嵌套,以便返回值是另一个 CASE 语句。

提示

THEN 后的返回值必须是相同的数据类型(字符串、数值、布尔值等),否则会报错。如果遇到此类情况,请考虑转换为通用数据类型,例如字符串。

CASE 语句是一种灵活的方法,用于控制箱数、每个箱中落入的值的范围以及如何命名这些箱。当存在非常小或非常大值的长尾需要分组而不希望在分布的某些部分中出现空箱时,它们尤其有用。某些数值范围具有需要在数据中重新创建的业务含义。许多 B2B 公司根据员工数量或收入将客户分为“企业”和“SMB”(中小型企业)类别,因为它们的购买模式不同。例如,假设我们正在考虑打折运费优惠,并且想知道有多少客户会受到影响。我们可以使用 CASE 语句将order_amount分成三个桶:

SELECT 
case when order_amount <= 100 then 'up to 100'
     when order_amount <= 500 then '100 - 500'
     else '500+' end as amount_bin
,case when order_amount <= 100 then 'small'
      when order_amount <= 500 then 'medium'
      else 'large' end as amount_category
,count(customer_id) as customers
FROM orders
GROUP BY 1,2
;

任意大小的箱子可能很有用,但有时固定大小的箱子更适合分析。固定大小的箱子可以通过几种方式实现,包括四舍五入、对数和 n-tiles。创建等宽箱子时,四舍五入很有用。四舍五入会减少值的精度,我们通常认为四舍五入是通过减少小数位数或完全删除它们来减少的。round函数的形式为:

round(value,number_of_decimal_places)

小数位数也可以是负数,允许该函数四舍五入到最接近的十位、百位、千位等。表 2-2 展示了带有从–3 到 2 的参数的四舍五入结果。

Table 2-2. 数字 123,456.789 的各种小数位数四舍五入

小数位数 公式 结果
2 round(123456.789,2) 123456.79
1 round(123456.789,1) 123456.8
0 round(123456.789,0) 123457
-1 round(123456.789,-1) 123460
-2 round(123456.789,-2) 123500
-3 round(123456.789,-3) 123000
SELECT round(sales,-1) as bin
,count(customer_id) as customers
FROM table
GROUP BY 1
;

对数是创建箱子的另一种方式,特别是在数据集中,最大值的数量级远大于最小值的情况下。家庭财富的分布、互联网上不同属性的网站访客数量以及地震的震动力量等都是具有此属性的现象的示例。虽然它们不会创建等宽箱子,但对数会创建具有有用模式的箱子。刷新一下记忆,对数是必须将 10 提高到产生该数字的指数:

log(number) = exponent

在这种情况下,10 称为底数,这通常是数据库中的默认实现,但从技术上讲,底数可以是任何数字。表 2-3 显示了几个 10 的幂的对数。

Table 2-3. 对log函数在 10 的幂上的结果

公式 结果
log(1) 0
log(10) 1
log(100) 2
log(1000) 3
log(10000) 4

在 SQL 中,log函数返回其参数的对数,该参数可以是常数或字段:

SELECT log(sales) as bin
,count(customer_id) as customers
FROM table
GROUP BY 1
;

log函数可以用于任何正值,而不仅仅是 10 的倍数。但是,当值小于或等于 0 时,对数函数无法工作;它将返回 null 或错误,这取决于数据库。

n-Tiles

您可能熟悉数据集的中位数,或者中间值。这是第 50 百分位值。一半的值大于中位数,另一半小于中位数。对于四分位数,我们填充 25 百分位和 75 百分位值。在第 25 百分位,四分之一的值较小,三分之三较大;在第 75 百分位,三分之三的值较小,四分之一较大。分位数将数据集分成 10 等份。通过使这个概念通用化,n-tiles允许我们计算数据集的任何百分位数:第 27 百分位数、第 50.5 百分位数等。

许多数据库内置了median函数,但对其余功能依赖于更通用的 n-tile 函数。这些函数是窗口函数,跨越多行计算以返回单行的值。它们接受一个参数,指定要将数据分割为的区间数,并且可以选择使用PARTITION BY和/或ORDER BY子句:

ntile(num_bins) over (partition by... order by...)

举个例子,假设我们有 12 笔交易,订单金额分别为$19.99、$9.99、$59.99、$11.99、$23.49、$55.98、$12.99、$99.99、$14.99、$34.99、$4.99 和$89.99。使用 10 个区间对ntile进行计算,对每个订单金额排序并分配一个从 1 到 10 的区间:

order_amount  ntile
------------  -----
4.99          1
9.99          1
11.99         2
12.99         2
14.99         3
19.99         4
23.49         5
34.99         6
55.98         7
59.99         8
89.99         9
99.99         10

这可以通过首先计算子查询中每行的ntile,然后在使用minmax查找值范围的上下界的外部查询中使用来实践中对记录进行分箱:

SELECT ntile
,min(order_amount) as lower_bound
,max(order_amount) as upper_bound
,count(order_id) as orders
FROM
(
    SELECT customer_id, order_id, order_amount
    ,ntile(10) over (order by order_amount) as ntile
    FROM orders
) a
GROUP BY 1
;

一个相关的函数是percent_rank。与返回数据所在区间不同,percent_rank返回百分位数。它不带参数,但需要括号,并且可以带有PARTITION BY和/或ORDER BY子句:

percent_rank() over (partition by... order by...)

尽管在分箱方面不如ntile有用,percent_rank可用于创建连续分布,或者作为报告或进一步分析的输出本身使用。由于需要对所有行进行排序,对大数据集进行计算可能会很昂贵。通过过滤表格仅包含所需数据集有助于减少计算成本。某些数据库实现了这些函数的近似版本,这些版本计算速度更快,并且如果不需要绝对精确性,则通常返回高质量的结果。在第六章中讨论异常检测时,我们将进一步探讨 n-tile 的其他用途。

在许多情境中,查看数据分布没有单一正确或客观最佳的方式。分析人员可以在理解数据和向他人展示数据方面使用上述技巧。然而,数据科学家需要运用判断力,并且在分享敏感数据的时候必须带着他们的道德雷达。

分析:数据质量

当涉及创建良好的分析时,数据质量是绝对关键的。尽管这似乎是显而易见的,但这是我多年从事数据工作中学到的最艰难的教训之一。过于关注数据处理的机制、找到巧妙的查询技术和恰到好处的可视化可能会导致利益相关者忽略所有这些,并指出一个数据不一致性。确保数据质量可能是分析中最难和最令人沮丧的部分之一。谚语“垃圾进,垃圾出”只捕捉了问题的一部分。好的输入再加上不正确的假设也可能导致垃圾输出。

与基本事实进行数据比较,或者已知为真的内容,是理想的,尽管并非总是可能的。例如,如果您正在使用生产数据库的副本工作,可以比较每个系统中的行数,以验证所有行是否已到达副本数据库。在其他情况下,您可能了解某个月销售的金额和数量,因此可以查询数据库以确保销售的sum和记录的count匹配。通常,您的查询结果与预期值之间的差异取决于您是否应用了正确的过滤器,例如排除取消订单或测试帐户;您如何处理空值和拼写异常;以及您是否在表之间设置了正确的JOIN条件。

档案记录是在数据质量问题对结果和从数据中得出的结论产生负面影响之前早期发现数据质量问题的一种方法。档案记录揭示了空值,需要解密的分类编码,需要解析的具有多个值的字段以及不寻常的日期时间格式。档案记录还可以揭示由于跟踪更改或停机而导致的数据中的间隙和步变。数据很少是完美的,通常只有通过对数据的分析使用才能发现数据质量问题。

检测重复项

重复 是指有两行(或更多)信息相同的情况。重复项可能由于多种原因存在。在数据输入时可能出现错误,如果存在某些手动步骤。跟踪调用可能会触发两次。处理步骤可能会运行多次。您可能会意外创建隐藏的多对多连接。无论它们如何出现,重复项都可能严重影响您的分析。我记得在我职业生涯早期,我曾经认为发现了一个很好的发现,只是产品经理指出我的销售数字是实际销售的两倍。这令人尴尬,破坏了信任,并且需要重新工作,有时候还需要对代码进行痛苦的审查以找出问题。我已经学会了在进行时检查重复项。

幸运的是,在我们的数据中找到重复项相对比较容易。一种方法是检查一个样本,所有列都有序:

SELECT column_a, column_b, column_c...
FROM table
ORDER BY 1,2,3...
;

这将显示数据是否充满重复记录,例如,在查看全新数据集时,当您怀疑某个过程正在生成重复记录时,或者在可能的笛卡尔JOIN之后。如果只有少量重复记录,则可能不会显示在样本中。滚动数据以尝试发现重复项对眼睛和大脑来说是一种负担。发现重复项的更系统的方法是SELECT列,然后count行数(这可能与直方图的讨论看起来很熟悉!):

SELECT count(*)
FROM
(
    SELECT column_a, column_b, column_c...
    , count(*) as records
    FROM...
    GROUP BY 1,2,3...
) a
WHERE records > 1
;

这将告诉您是否存在任何重复情况。如果查询返回 0,则一切正常。要获取更多详细信息,您可以列出记录的数量(2、3、4 等):

SELECT records, count(*)
FROM
(
    SELECT column_a, column_b, column_c..., count(*) as records
    FROM...
    GROUP BY 1,2,3...
) a
WHERE records > 1
GROUP BY 1
;
注意

作为子查询的替代方案,您可以使用HAVING子句并将所有内容保留在单个主查询中。由于它在聚合和GROUP BY之后进行评估,HAVING可用于在聚合值上进行过滤:

SELECT column_a, column_b, column_c..., count(*) as records
FROM...
GROUP BY 1,2,3...
HAVING count(*) > 1
;

我更喜欢使用子查询,因为我发现它们是组织逻辑的一种有用方式。第八章将讨论评估顺序和保持 SQL 查询组织的策略。

要查看哪些记录存在重复,您可以列出所有字段,然后使用这些信息来查找存在问题的记录:

SELECT *
FROM
(
    SELECT column_a, column_b, column_c..., count(*) as records
    FROM...
    GROUP BY 1,2,3...
) a
WHERE records = 2
;

检测重复记录是一回事;解决它们又是另一回事。了解重复记录出现的原因几乎总是有用的,如果可能的话,修复上游问题。可以改进数据处理以减少或消除重复吗?是否存在 ETL 过程中的错误?您是否未考虑JOIN中的一对多关系?接下来,我们将介绍一些处理和移除 SQL 中重复记录的选项。

使用 GROUP BY 和 DISTINCT 进行去重

重复事件时常发生,而且它们并不总是由于数据质量问题引起的。例如,假设我们想要查找已成功完成交易的所有客户的列表,以便为他们的下一个订单发送优惠券。我们可能会JOIN customers 表和 transactions 表,这将限制返回的记录仅限于出现在 transactions 表中的客户:

SELECT a.customer_id, a.customer_name, a.customer_email
FROM customers a
JOIN transactions b on a.customer_id = b.customer_id
;

这将为每个客户每个交易返回一行,但希望至少有几个客户进行了多次交易。我们意外地创建了重复记录,并非因为存在任何潜在的数据质量问题,而是因为我们没有注意避免结果中的重复。幸运的是,有几种方法可以在 SQL 中避免这种情况。去除重复记录的一种方法是使用关键词DISTINCT

SELECT distinct a.customer_id, a.customer_name, a.customer_email
FROM customers a
JOIN transactions b on a.customer_id = b.customer_id
;

另一种选择是使用 GROUP BY,虽然通常与聚合有关,但也会像 DISTINCT 一样进行去重。我记得第一次看到同事使用 GROUP BY 进行去重时,我甚至没有意识到这是可能的。我觉得它比 DISTINCT 稍显不直观,但结果是一样的:

SELECT a.customer_id, a.customer_name, a.customer_email
FROM customers a
JOIN transactions b on a.customer_id = b.customer_id
GROUP BY 1,2,3
;

另一种有用的技术是执行返回每个实体一行的聚合。虽然从技术上讲不是去重,但效果类似。例如,如果我们有同一客户的多笔交易并且需要返回每个客户的一条记录,我们可以找到min(第一个)和/或max(最近)的transaction_date

SELECT customer_id
,min(transaction_date) as first_transaction_date
,max(transaction_date) as last_transaction_date
,count(*) as total_orders
FROM table
GROUP BY customer_id
;

重复数据或包含多条记录每个实体的数据,即使它们在技术上不是重复的,是查询结果不正确的最常见原因之一。如果突然查询返回的客户数或总销售额比预期多许多倍,您可能会怀疑重复数据是原因。幸运的是,有几种技术可以应用来防止这种情况发生。

另一个常见问题是缺失数据,接下来我们会讨论。

准备工作:数据清理

分析通常会揭示哪些变更可以使数据对分析更有用。一些步骤包括 CASE 转换、空值调整和数据类型更改。

使用 CASE 转换清洗数据

CASE 语句可以用于执行各种清洗、增强和汇总任务。有时数据存在且准确,但如果值被标准化或分组到类别中,则对分析更有用。CASE 语句的结构在本章早些时候已经介绍过,在分箱部分。

非标准值由于各种原因而存在。值可能来自具有略有不同选项列表的不同系统,系统代码可能已更改,选项可能以不同语言呈现给客户,或者客户可能能够自行填写值而不是从列表中选择。

想象一个字段包含有关个人性别的信息。表示女性的值包括“F”、“female”和“femme”。我们可以将值标准化如下:

CASE when gender = 'F' then 'Female'
     when gender = 'female' then 'Female'
     when gender = 'femme' then 'Female'
     else gender 
     end as gender_cleaned

CASE 语句还可以用于添加原始数据中不存在的分类或增强功能。例如,许多组织使用净推荐值(Net Promoter Score,NPS)来监测客户情绪。NPS 调查要求受访者评价他们有多大可能向朋友或同事推荐公司或产品,评分从 0 到 10。评分为 0 到 6 的人被视为贬损者,7 和 8 是中立者,9 和 10 是推荐者。最终得分通过从推荐者百分比中减去贬损者百分比计算得出。调查结果数据集通常包括可选的自由文本评论,并有时会使用组织已知的被调查者信息进行增强。给定一个 NPS 调查响应数据集,第一步是将响应分组为贬损者、中立者和推荐者类别:

SELECT response_id
,likelihood
,case when likelihood <= 6 then 'Detractor'
      when likelihood <= 8 then 'Passive'
      else 'Promoter'
     end as response_type
FROM nps_responses
;

注意,在评估字段和返回数据类型之间可能存在数据类型差异。在这种情况下,我们正在检查一个整数并返回一个字符串。列出所有值并使用 IN 列表也是一种选择。IN 运算符允许您指定一个项目列表,而不必为每个项目单独编写相等性。当输入不连续或值不应按顺序分组时,这是非常有用的:

case when likelihood in (0,1,2,3,4,5,6) then 'Detractor'
     when likelihood in (7,8) then 'Passive'
     when likelihood in (9,10) then 'Promoter'
     end as response_type

CASE 语句可以考虑多个列,并且可以包含 AND/OR 逻辑。它们也可以是嵌套的,虽然通常可以通过 AND/OR 逻辑避免这种情况:

case when likelihood <= 6 
          and country = 'US' 
          and high_value = true 
          then 'US high value detractor'
     when likelihood >= 9 
          and (country in ('CA','JP') 
               or high_value = true
              ) 
          then 'some other label'
     ... end

另一个可以使用 CASE 语句做的有用事情是创建指示某个值是否存在的标志,而不返回实际值。这在分析配置文件时非常有用,可以理解某个属性的存在有多普遍。标记的另一个用途是在准备用于统计分析的数据集时。在这种情况下,标志也称为虚拟变量,取值为 0 或 1,指示某些定性变量的存在或不存在。例如,我们可以使用 CASE 语句在 genderlikelihood(推荐概率)字段上创建 is_femaleis_promoter 标志:

SELECT customer_id
,case when gender = 'F' then 1 else 0 end as is_female
,case when likelihood in (9,10) then 1 else 0 end as is_promoter
FROM ...
;

如果您正在处理一个每个实体有多行的数据集,比如订单中的行项目,您可以使用包含在聚合函数中的 CASE 语句来展开数据,并同时将其转换为标志,返回值为 1 或 0。我们之前看到 BOOLEAN 数据类型通常用于创建标志(表示某些属性的存在或不存在)。在这里,1 代表 TRUE,0 代表 FALSE,以便可以应用 max 聚合函数。其工作原理是对于每个客户,CASE 语句对于水果类型为“苹果”的任何行都返回 1。然后计算 max,将从任何行中返回最大值。只要客户至少购买过一个苹果,标志就会是 1;否则为 0:

SELECT customer_id
,max(case when fruit = 'apple' then 1 
          else 0 
          end) as bought_apples
,max(case when fruit = 'orange' then 1 
          else 0 
          end) as bought_oranges
FROM ...
GROUP BY 1
;

您还可以构建更复杂的条件以生成标志,例如在给定阈值或某物品数量后才进行标记为 1 的数值:

SELECT customer_id
,max(case when fruit = 'apple' and quantity > 5 then 1 
          else 0 
          end) as loves_apples
,max(case when fruit = 'orange' and quantity > 5 then 1 
          else 0 
          end) as loves_oranges
FROM ...
GROUP BY 1
;

CASE 语句非常强大,正如我们所见,它们可以用于清理、丰富数据集,并为数据集添加标志或添加虚拟变量。在下一节中,我们将看一些专门处理空值的 CASE 语句相关特殊函数。

类型转换和转换

数据库中的每个字段都定义了一个数据类型,我们在本章开头进行了回顾。当数据插入表中时,不符合字段类型的值将被数据库拒绝。字符串不能插入到整数字段中,布尔值不能出现在日期字段中。大多数情况下,我们可以默认数据类型,并将字符串函数应用于字符串,日期函数应用于日期等等。然而偶尔我们需要覆盖字段的数据类型并强制其成为其他类型。这就是类型转换和转换的作用所在。

类型转换函数 允许具有适当格式的数据片段从一种数据类型转换为另一种。语法有几种基本等效的形式。一种改变数据类型的方式是使用cast函数,cast (*input* as *data_type*),或者使用两个冒号,*input* :: *data_type*。这两者是等效的,并将整数 1,234 转换为字符串:

cast (1234 as varchar)

1234::varchar

将整数转换为字符串在 CASE 语句中非常有用,用于对一些无上限或下限值的数值进行分类。例如,在以下代码中,保留小于或等于 3 的值为整数,同时对更高值返回字符串“4+”会导致错误:

case when order_items <= 3 then order_items
     else '4+' 
     end

将整数转换为 VARCHAR 类型可以解决这个问题:

case when order_items <= 3 then order_items::varchar
     else '4+' 
     end

类型转换在将本应为整数的值从字符串中解析出来后变得非常方便,然后我们希望对这些值进行聚合或者使用数学函数。假设我们有一个价格数据集,但这些值包含美元符号(\(),因此字段的数据类型是 VARCHAR。我们可以使用一个名为`replace`的函数来去除\)字符,这将在我们研究文本分析中的第五章中更详细地讨论:

SELECT replace('$19.99','$','');
replace
-------
9.99

然而,结果仍然是 VARCHAR 类型,因此尝试应用聚合将返回错误。为了解决这个问题,我们可以将结果cast为 FLOAT 类型:

replace('$19.99','$','')::float
cast(replace('$19.99','$','')) as float

日期和日期时间可以以令人眼花缭乱的格式出现,了解如何将它们转换为所需格式是有用的。这里我会展示一些类型转换的示例,第三章 将更详细地介绍日期和日期时间的计算。举个简单的例子,想象一下,交易或事件数据通常以 TIMESTAMP 的形式到达数据库,但我们希望按天汇总某个值,例如交易。简单地按时间戳分组会导致不必要的行数。将 TIMESTAMP 转换为 DATE 可减少结果的大小并实现我们的汇总目标:

SELECT tx_timestamp::date, count(transactions) as num_transactions
FROM ...
GROUP BY 1
;

同样地,当 SQL 函数需要 TIMESTAMP 参数时,DATE 可以转换为 TIMESTAMP。有时年、月和日存储在单独的列中,或者它们因从较长字符串中分析出来而成为单独的元素。然后需要将它们组装回日期。为此,我们使用连接运算符 ||(双管道)或 concat 函数,然后将结果转换为 DATE。任何这些语法都有效且返回相同的值:

(year || ',' || month|| '-' || day)::date

或者等价地:

cast(concat(year, '-', month, '-', day) as date)

另一种在字符串值和日期之间进行转换的方法是使用 date 函数。例如,我们可以像上面那样构造一个字符串值,并将其转换为日期:

date(concat(year, '-', month, '-', day))

to_datatype 函数可以同时接受值和格式字符串,从而更多地控制数据转换方式。表 2-4 总结了这些函数及其用途。在转换进出 DATE 或 DATETIME 格式时特别有用,因为它们允许您指定日期和时间元素的顺序。

表 2-4. to_datatype 函数

函数 用途
to_char 将其他类型转换为字符串。
to_number 将其他类型转换为数值。
to_date 将其他类型转换为日期,并指定日期部分。
to_timestamp 将其他类型转换为日期,并指定日期和时间部分。

数据库有时会自动转换数据类型。这称为类型强制转换。例如,INT 和 FLOAT 数值通常可以在数学函数或聚合中一起使用,而无需显式更改类型。CHAR 和 VARCHAR 值通常可以混合使用。某些数据库会将 BOOLEAN 字段强制转换为 0 和 1 的值,其中 0 表示 FALSE,1 表示 TRUE,但某些数据库要求您显式转换值。某些数据库在混合日期和日期时间在结果集和函数中时要求更加严格。您可以阅读文档,或者进行一些简单的查询实验,了解您正在使用的数据库如何隐式和显式处理数据类型。通常有办法实现您想要的效果,尽管有时需要在查询中创造性地使用函数。

处理空值:coalesce、nullif、nvl 函数

当我开始处理数据时,Null 是我不得不适应的比较奇怪的概念之一。在日常生活中,我们习惯于处理具体的事物数量,而不是空值。Null 在数据库中有着特殊的含义,是关系数据库发明者埃德加·科德引入的,用于确保数据库有一种方式来表示丢失的信息。如果有人问我有多少个降落伞,我可以回答“零”。但如果从未问过这个问题,我就会有空的降落伞。

Null 值可以表示未收集到数据或对该行不适用的字段。当向表中添加新列时,以前创建的行的值将为空,除非显式填充为其他值。当两个表通过外连接进行连接时,任何在第二个表中找不到匹配记录的字段将显示为 Null。

Null 值对于某些聚合和分组是有问题的,不同类型的数据库以不同的方式处理它们。例如,假设我有五条记录,分别是 5、10、15、20 和 Null。这些数的总和是 50,但平均值则是 10 或 12.5,这取决于是否将空值计入分母。整个问题也可能被认为无效,因为其中一个值是空的。对于大多数数据库函数,空输入将返回空输出。包含空值的等式和不等式也会返回空值。如果你对空值不加注意,你的查询可能会输出各种意料之外和令人沮丧的结果。

当定义表时,它们可以允许空值,拒绝空值,或在字段原本为空时填充默认值。实际操作中,这意味着你不能总是依赖字段显示为 Null,因为它可能已经填充了默认值,比如 0。有一次我和一个数据工程师就源系统中的空日期默认为我们数据仓库中的“1970-01-01”进行了长时间的辩论。我坚持认为日期应该是空值,以反映其未知或不适用的事实。工程师指出,我可以记得筛选这些日期或使用 CASE 语句将它们改回空值。最终,我成功地指出,总有一天会有另一个不那么了解默认日期细微差别的用户来运行查询,得到关于公司成立前约一年的令人困惑的客户群集。

Null 值通常对你希望进行的分析是不方便的或不合适的。它们还可能使得分析结果对预期的受众变得混淆。商业人士未必了解如何解释空值,或者可能假设空值代表数据质量问题。

有几种方法可以使用 CASE 语句和专用的coalescenullif函数替换空值。我们之前看到 CASE 语句可以检查条件并返回值。它们还可以用于检查空值,并且如果找到空值,则用另一个值替换它:

case when num_orders is null then 0 else num_orders end

case when address is null then 'Unknown' else address end

case when column_a is null then column_b else column_a end

coalesce 函数是实现这一目标的更紧凑的方式。它接受两个或多个参数,并返回第一个非空参数:

coalesce(num_orders,0)

coalesce(address,'Unknown')

coalesce(column_a,column_b)

coalesce(column_a,column_b,column_c)
注意

函数nvl存在于某些数据库中,类似于coalesce,但它只允许两个参数。

nullif 函数比较两个数字,如果它们不相等,则返回第一个数字;如果它们相等,则返回空值。运行此代码:

nullif(6,7)

返回 6,而以下代码返回空值:

nullif(6,6)

nullif 等同于以下更冗长的 CASE 语句:

case when 6 = 7 then 6 
     when 6 = 6 then null
     end

当你知道某个默认值已经插入到数据库中时,此函数可以将值转换回空值。例如,对于我的默认时间示例,我们可以通过以下方式将其更改回空值:

nullif(date,'1970-01-01')
警告

WHERE子句中过滤数据时,空值可能会带来问题。返回空值的值相对直接:

WHERE my_field is null

但是,假设my_field包含一些空值和一些水果名称。我希望返回所有不是苹果的行。看起来这应该可以工作:

WHERE my_field <> 'apple'

然而,某些数据库将排除所有“apple”行和my_field中的所有空值行。为了纠正这一点,SQL 应该同时过滤“apple”并明确通过 OR 连接条件包含空值:

WHERE my_field <> 'apple' or my_field is null

在处理数据时,空值是一个不可避免的事实。无论其产生的原因是什么,我们通常需要在分析中考虑它们,并将其作为数据清洗的目标。幸运的是,SQL 有许多方法可以检测它们,并且有几个有用的函数可以用来将空值替换为其他值。接下来,我们将看一下缺失数据,这是一个可能导致空值的问题,但其影响更广泛,因此值得专门一节来讨论。

缺失数据

数据可能因各种原因而丢失,每种原因都会对您决定如何处理数据的缺失产生影响。例如,某个字段可能不是由收集它的系统或流程所要求的,就像在电子商务结账流程中可选的“您是如何知道我们的?”字段。要求填写此字段可能会给客户带来不便,并减少成功的结账次数。或者,由于代码错误或人为失误,例如在医疗问卷中面试者错过了第二页问题,通常需要数据但未收集。数据收集方式的变化可能导致在变更前或变更后的记录中存在缺失值。例如,跟踪移动应用交互的工具可能添加一个额外字段记录交互是点击还是滚动,或者由于功能变更而移除另一个字段。数据可能成为孤立数据,当一个表引用另一个表中的值,而该行或整个表已被删除或尚未加载到数据仓库中。最后,数据可能可用,但不足以进行所需的分析粒度。例如,订阅业务中,客户按年支付月度产品,我们希望分析每月收入。

除了使用直方图和频率分析对数据进行分析外,我们还经常可以通过比较两个表中的值来检测缺失数据。例如,我们可能期望transactions表中的每个客户也在customer表中有记录。要检查这一点,可以使用LEFT JOIN查询这些表,并添加WHERE条件来查找不存在于第二个表中的客户:

SELECT distinct a.customer_id
FROM transactions a
LEFT JOIN customers b on a.customer_id = b.customer_id
WHERE b.customer_id is null
;

缺失数据本身可能是一个重要的信号,因此不要假设它总是需要修复或填充。缺失数据可以揭示底层系统设计或数据收集过程中的偏见。

具有缺失字段的记录可以完全过滤掉,但通常我们希望保留它们,并根据我们对预期或典型值的了解进行一些调整。我们有一些选择,称为填充技术,用于填补缺失数据。这些包括用数据集的平均值或中位数填充,或者用前一个值填充。记录缺失数据及其替换方式的文档化非常重要,因为这可能会影响数据的下游解释和使用。例如,在机器学习中使用数据时,填充值可能特别有问题。

常见的选项是用常量值填充缺失数据。当某些记录的值已知但未在数据库中填充时,用常量值填充可能很有用。例如,假设由于软件错误导致未为名为“xyz”的项目填充price,但我们知道价格始终为$20。可以在查询中添加 CASE 语句来处理此问题:

case when price is null and item_name = 'xyz' then 20 
                                              else price 
                                              end as price

另一种选择是填充衍生值,可以是其他列的数学函数或 CASE 语句。例如,想象一下,我们为每笔交易的net_sales金额有一个字段。由于漏洞,有些行没有填充这个字段,但它们填充了gross_salesdiscount字段。我们可以通过从gross_sales中减去discount来计算net_sales

SELECT gross_sales - discount as net_sales...

可以使用数据集中其他行的值来填充缺失的数值。从上一行传递数值称为向前填充,而从下一行传递数值称为向后填充。这可以通过laglead窗口函数实现,分别。例如,想象一下,我们的交易表有一个product_price字段,存储客户为product支付的未折扣价格。偶尔该字段未填充,但我们可以假设价格与上一个购买该product的客户支付的价格相同。我们可以使用lag函数在PARTITION BYproduct的条件下进行填充,以确保价格仅从相同的product中提取,并在适当的日期ORDER BY以确保价格是从最近的先前交易中提取的:

lag(product_price) over (partition by product order by order_date)

lead函数可用于在以下交易中使用product_price进行填充。或者,我们可以计算product的价格的平均值,并用其填充缺失的数值。使用上一个、下一个或平均值进行填充涉及对典型数值和合理包含在分析中的数值进行一些假设。检查结果确保其合理,并注明在数据不可用时进行了插值永远是一个好主意。

对于可用但粒度不够的数据,我们通常需要在数据集中创建额外的行。例如,假设我们有一个customer_subscriptions表,具有subscription_dateannual_amount字段。通过除以 12,我们可以将这个年度订阅金额分成 12 个相等的月度收入金额,有效地将 ARR(年度循环收入)转换为 MRR(月度循环收入)。

SELECT customer_id
,subscription_date
,annual_amount
,annual_amount / 12 as month_1
,annual_amount / 12 as month_2
...
,annual_amount / 12 as month_12
FROM customer_subscriptions
;

如果订阅期限可以为两年、三年或五年以及一年,这将变得有点繁琐。如果我们想要的是月份的实际日期,这也没有帮助。理论上,我们可以编写类似这样的查询:

SELECT customer_id
,subscription_date
,annual_amount
,annual_amount / 12 as '2020-01'
,annual_amount / 12 as '2020-02'
...
,annual_amount / 12 as '2020-12'
FROM customer_subscriptions
;

但是,如果数据包括来自不同时间的客户的订单,硬编码月份名称将不准确。我们可以使用 CASE 语句与硬编码的月份名称结合使用,但这样做又很繁琐,而且随着添加更复杂的逻辑,很可能出错。相反,通过JOIN到日期维度表等表来创建新行提供了一个优雅的解决方案。

日期维度是一个静态表,每天一行,可以包括扩展的日期属性,比如星期几、月份名、月末日期和财政年度。日期要足够远以覆盖所有预期的使用情况,从过去延伸到未来。由于每年只有 365 或 366 天,即使覆盖 100 年,表也不会占用很多空间。图 2-3 展示了日期维度表中数据的示例。创建日期维度的示例代码可以在本书的GitHub 网站找到。

图 2-3. 带有日期属性的日期维度表

如果你使用的是 Postgres 数据库,generate_series函数可以用来创建一个日期维度,无论是用来初始化表还是在创建表不可行的情况下。它的形式如下:

generate_series(*start*, *stop*, *step interval*)

在这个函数中,*start*是系列中希望的第一个日期,*stop*是最后一个日期,*step interval*是值之间的时间间隔。*step interval*可以取任何值,但对于日期维度来说,一天是合适的:

SELECT * 
FROM generate_series('2000-01-01'::timestamp,'2030-12-31', '1 day')

generate_series函数要求至少有一个参数是 TIMESTAMP,因此将“2000-01-01”转换为 TIMESTAMP。然后,我们可以创建一个查询,使每天都有一行,无论客户是否在特定日期下订单。当我们希望确保每天都计算一个客户时,或者当我们专门希望计算或分析客户没有购买的日期时,这非常有用:

SELECT a.generate_series as order_date, b.customer_id, b.items
FROM
(
    SELECT *
    FROM generate_series('2020-01-01'::timestamp,'2020-12-31','1 day')
) a
LEFT JOIN 
(
    SELECT customer_id, order_date, count(item_id) as items
    FROM orders
    GROUP BY 1,2
) b on a.generate_series = b.order_date
;

回到我们的订阅示例,我们可以使用日期维度来创建每个月的记录,通过将日期维度与订阅日期和 11 个月后(共 12 个月)之间的日期进行JOIN

SELECT a.date
,b.customer_id
,b.subscription_date
,b.annual_amount / 12 as monthly_subscription
FROM date_dim a
JOIN customer_subscriptions b on a.date between b.subscription_date 
and b.subscription_date + interval '11 months'
;

数据可能因各种原因缺失,了解根本原因对于决定如何处理数据非常重要。有许多选项可以找到和替换缺失数据。这些选项包括使用 CASE 语句设置默认值,通过对同一行中的其他字段执行计算来导出值,以及从同一列中的其他值进行插值。

数据清洗是数据准备过程中的重要组成部分。数据可能因多种原因需要进行清洗。一些数据清洗是为了修复数据质量问题,比如原始数据中存在不一致或缺失值,而其他数据清洗则是为了使进一步的分析更容易或更有意义。SQL 的灵活性允许我们以多种方式执行清洗任务。

数据清洗完成后,准备过程中的一个常见下一步是塑形数据集。

准备工作:数据塑形

塑造数据 指的是操作数据在列和行中表示方式的过程。数据库中的每张表都有一个形状。每个查询的结果集也有一个形状。塑造数据可能看起来像是一个相当抽象的概念,但如果你处理足够多的数据,你会看到它的价值。这是一个可以学习、练习和掌握的技能。

在塑造数据中,一个最重要的概念是确定你需要的数据的粒度。就像岩石可以从大岩石到沙粒,甚至进一步到微观尘埃一样,数据的详细程度也可以有不同的层次。例如,如果一个国家的人口是一块大岩石,那么一个城市的人口是一块小石头,一个家庭的人口就是一粒沙子。在较小层次的详细数据可能包括个体的出生和死亡,或者从一个城市或国家搬到另一个城市或国家的迁移。

展平数据 是塑造中的另一个重要概念。这指的是减少表示实体的行数,甚至减少到单行。将多个表连接在一起创建单个输出数据集是展平数据的一种方法。另一种方法是通过聚合。

在本节中,我们首先会涵盖一些选择数据形状的考虑因素。然后我们将看一些常见的用例:透视和反透视。我们将在剩余章节中看到为特定分析塑造数据的示例。第八章 将更详细地讨论在创建用于进一步分析的数据集时,如何保持复杂 SQL 的组织结构。

对于哪种输出:BI、可视化、统计、ML

决定如何使用 SQL 来塑造你的数据,很大程度上取决于之后你打算如何处理这些数据。通常情况下,输出尽可能少的行数,同时仍满足你对数据粒度的需求是个不错的主意。这样可以充分利用数据库的计算能力,减少从数据库到其他地方移动数据的时间,以及减少你或其他人需要在其他工具中进行的处理量。你的输出可能会进入一些其他工具,比如用于报告和仪表板的 BI 工具,供业务用户检查的电子表格,诸如 R 这样的统计工具,或者 Python 中的机器学习模型,或者你可能直接将数据输出到用各种工具创建的可视化中。

当将数据输出到商业智能工具以生成报告和仪表板时,理解使用场景非常重要。数据集可能需要非常详细,以便用户进行探索和切片。它们可能需要小而聚合,并包括特定的计算,以便在执行仪表板中实现快速加载和响应时间。了解工具的工作方式,以及它是否更适合处理较小数据集,还是设计成在较大数据集上执行自己的聚合,都非常重要。没有“一刀切”的答案。你对数据如何使用的了解越多,就越能准备好适当地塑造数据。

对于可视化,通常最适合使用更小、聚合和高度特定的数据集,无论是在商业软件中创建还是使用像 R、Python 或 JavaScript 这样的编程语言。考虑最终用户将需要对其进行过滤的聚合和切片级别,或各种元素。有时,数据集需要每个切片一个行,以及一个“全部”切片。你可能需要将两个查询 UNION 在一起——一个在详细级别,一个在“全部”级别。

在为统计软件包或机器学习模型创建输出时,理解所研究的核心实体、所需的聚合级别和属性或特征非常重要。例如,模型可能需要每个客户一个记录,带有多个属性,或者每个交易一个记录,带有其关联的属性以及客户属性。通常,建模输出将遵循 Hadley Wickham 提出的“整洁数据”概念。² 整洁数据具有以下特性:

  1. 每个变量形成一列。

  2. 每个观察结果形成一行。

  3. 每个数值都是一个单元格。

接下来我们将看看如何使用 SQL 将数据从数据库中的现有结构转换为分析所需的任何其他透视或非透视结构。

使用 CASE 语句进行数据透视

数据透视表 是一种通过根据一个属性的值排列数据行,并根据另一个属性的值排列数据列的方式来汇总数据集的方法。在每行和列的交汇处,计算汇总统计数据,如 sumcountavg。数据透视表通常是总结业务数据的好方法,因为它们将数据重塑为更紧凑和易于理解的形式。数据透视表因其在 Microsoft Excel 中的实现而广为人知,该软件具有拖放界面用于创建数据摘要。

可以使用 CASE 语句和一个或多个聚合函数在 SQL 中创建透视表或透视输出。到目前为止,我们已经多次看到 CASE 语句,重塑数据是它们的另一个主要用途。例如,假设我们有一个 orders 表,每个客户的每次购买都有一行。要扁平化数据,GROUP BY customer_idsum order_amount

SELECT customer_id
,sum(order_amount) as total_amount
FROM orders
GROUP BY 1
; 

customer_id  total_amount
-----------  ------------
123          59.99
234          120.55
345          87.99
...          ...

要创建一个透视,我们还将为属性的每个值创建列。假设 orders 表还有一个包含购买的产品类型和 order_dateproduct 字段。要创建透视输出,GROUP BY order_date,并 sum 一个 CASE 语句的结果,该语句在满足产品名称条件的行时返回 order_amount

SELECT order_date
,sum(case when product = 'shirt' then order_amount 
          else 0 
          end) as shirts_amount
,sum(case when product = 'shoes' then order_amount 
          else 0 
          end) as shoes_amount
,sum(case when product = 'hat' then order_amount 
          else 0 
          end) hats_amount
FROM orders
GROUP BY 1
;

order_date  shirts_amount  shoes_amount  hats_amount
----------  -------------  ------------  -----------
2020-05-01  5268.56        1211.65       562.25
2020-05-02  5533.84        522.25        325.62
2020-05-03  5986.85        1088.62       858.35
...         ...            ...           ...

请注意,在 sum 聚合中,您可以选择使用 "else 0" 来避免结果集中的空值。然而,在 countcount distinct 中,不应包含 ELSE 语句,因为这样做会增加结果集。这是因为数据库不会计算空值,但会计算零等替代值。

使用 CASE 语句进行透视非常方便,有了这种能力,可以打开数据仓库表设计的新局面,它们是长而窄的,而不是宽的,这对存储稀疏数据可能更好,因为向表添加列可能是一个昂贵的操作。例如,与其在许多不同的列中存储各种客户属性,不如在一张表中为每个客户包含多条记录,每个属性在一个单独的行中,具有 attribute_nameattribute_value 字段指定属性及其值。然后可以根据需要对数据进行透视,以组装具有所需属性的客户记录。当存在许多稀疏属性(只有少数客户对许多属性具有值)时,这种设计非常有效。

当需要旋转的项目数量有限时,结合聚合和 CASE 语句进行数据透视非常有效。对于那些已经使用过其他编程语言的人来说,这本质上是循环,但是一行一行地明确写出来。这样做可以给你很多控制权,比如如果你想在每一列计算不同的指标,但这也可能会很乏味。使用 CASE 语句进行透视在新值不断到来或者变化迅速时效果不佳,因为 SQL 代码需要经常更新。在这些情况下,将计算推向分析堆栈的另一层,比如 BI 工具或统计语言,可能更合适。

使用 UNION 语句进行反向旋转

有时我们遇到相反的问题,需要将存储在列中的数据转换为行,以创建整洁的数据。这个操作称为unpivoting。可能需要进行 unpivoting 的数据集是那些处于数据透视表格式的数据。例如,展示了从 1980 年开始每 10 年的北美国家的人口数据在图 2-4 中。

图 2-4. 按年份的国家人口(单位:千)

要将其转换为每个国家每年一行的结果集,我们可以使用UNION运算符。UNION是将多个查询的数据集合并为单个结果集的一种方式。有两种形式,UNIONUNION ALL。在使用UNIONUNION ALL时,每个组成查询中的列数必须匹配。数据类型必须匹配或兼容(整数和浮点数可以混合使用,但整数和字符串不能)。结果集中的列名来自第一个查询。因此,对剩余查询中的字段进行别名是可选的,但可以使查询更易读:

SELECT country
,'1980' as year
,year_1980 as population
FROM country_populations
    UNION ALL
SELECT country
,'1990' as year
,year_1990 as population
FROM country_populations
    UNION ALL
SELECT country
,'2000' as year
,year_2000 as population
FROM country_populations
    UNION ALL
SELECT country
,'2010' as year
,year_2010 as population
FROM country_populations
;

country        year  population
-------------  ----  ----------
Canada         1980  24593
Mexico         1980  68347
United States  1980  227225
...            ...   ...

在这个例子中,我们使用一个常数来硬编码年份,以便跟踪人口值对应的年份。硬编码的值可以是任何类型,具体取决于您的用例。您可能需要显式转换某些硬编码的值,例如在输入日期时:

'2020-01-01'::date as date_of_interest

UNIONUNION ALL之间有什么区别?两者都可以用于以这种方式追加或堆叠数据,但它们略有不同。UNION从结果集中删除重复项,而UNION ALL保留所有记录,无论是否重复。UNION ALL速度更快,因为数据库不需要对数据进行一次遍历以查找重复项。它还确保每条记录最终都出现在结果集中。我倾向于使用UNION ALL,只有在怀疑有重复数据时才使用UNION

UNION数据也可以有助于将来自不同来源的数据整合在一起。例如,假设我们有一个每年数据表populations按国家统计的数据,还有一个年度国内生产总值或 GDP 的表gdp。一种选择是将这些表JOIN起来,得到一个包含人口和 GDP 两列的结果集:

SELECT a.country, a.population, b.gdp
FROM populations a
JOIN gdp b on a.country = b.country
;

另一种选择是将所有数据集UNION ALL起来,从而得到一个堆叠的数据集:

SELECT country, 'population' as metric, population as metric_value
FROM populations
    UNION ALL
SELECT country, 'gdp' as metric, gdp as metric_value
FROM gdp
;

您选择的方法主要取决于您分析所需的输出。当您在不同的表中具有多个不同的指标且没有任何单个表具有完整的实体集(在本例中是国家)时,后一种选择可能很有用。这是FULL OUTER JOIN的一种替代方法。

pivot 和 unpivot 函数

认识到 pivot 和 unpivot 的使用案例很常见,一些数据库供应商已经实现了可以用更少代码完成此操作的函数。Microsoft SQL Server 和 Snowflake 有 pivot 函数,它们在 WHERE 子句中以额外的表达式形式出现。在这里,聚合可以是任何聚合函数,如 sumavgvalue_column 是要聚合的字段,而每个 label_column 列出的标签将创建一个列:

SELECT...
FROM... 
    pivot(aggregation(value_column) 
          for label_column in (label_1, label_2, ...)
;

我们可以重写之前使用 CASE 语句的旋转示例,如下所示:

SELECT *
FROM orders
  pivot(sum(order_amount) for product in ('shirt','shoes'))
GROUP BY order_date
;

虽然这种语法比我们之前看到的 CASE 结构更紧凑,但仍然需要指定所需的列。因此,pivot 并未解决需要转换为列的新到达或快速变化的字段集的问题。Postgres 具有类似的 crosstab 函数,可在 tablefunc 模块中使用。

Microsoft SQL Server 和 Snowflake 也有 unpivot 函数,其工作方式与 WHERE 子句中的表达式类似,并将行转换为列:

SELECT...
FROM... 
    unpivot( value_column for label_column in (label_1, label_2, ...))
;

例如,前面示例中的 country_populations 数据可以按以下方式重塑:

SELECT *
FROM country_populations 
    unpivot(population for year in (year_1980, year_1990, year_2000, year_2010))
;

这里的语法比我们之前看到的 UNIONUNION ALL 方法更紧凑,但必须在查询中指定列的列表。

Postgres 具有 unnest 数组函数,可以通过其数组数据类型对数据进行 unpivot 处理。数组是元素的集合,在 Postgres 中,可以在方括号中列出数组的元素。该函数可以在 SELECT 子句中使用,并采用以下形式:

unnest(array[element_1, element_2, ...])

回到早期的国家和人口示例,这个查询返回与使用重复的 UNION ALL 子句的查询相同的结果:

SELECT 
country
,unnest(array['1980', '1990', '2000', '2010']) as year
,unnest(array[year_1980, year_1990, year_2000, year_2010]) as pop
FROM country_populations
;
country  year  pop
-------  ----  -----
Canada   1980  24593
Canada   1990  27791
Canada   2000  31100
...      ...   ...

数据集以许多不同的格式和形状到达,并且它们并不总是以我们输出所需的格式。通过旋转或反转数据来重新塑造它们有几种选项,可以使用 CASE 语句、UNION 或特定于数据库的函数。了解如何操作数据以按照您想要的方式进行塑形,将为您的分析和结果呈现方式提供更大的灵活性。

结论

准备数据进行分析可能感觉像是你在真正进行分析之前的工作,但它对于理解数据非常基础,我总是觉得这是值得花时间的事情。理解可能遇到的不同数据类型至关重要,你应该花时间了解每张表中的数据类型。数据概要分析帮助我们更多地了解数据集的内容,并检查其质量。在我的分析项目中,我经常回头进行概要分析,因为我在构建复杂查询时了解到更多数据并需要检查我的查询结果。数据质量可能永远是个问题,因此我们已经看过一些处理和增强数据集的方法。最后,了解如何塑造数据以创建正确的输出格式是至关重要的。我们将在本书中的各种分析上下文中反复看到这些主题。下一章,关于时间序列分析,将开始我们对特定分析技术的探索。

¹ 约翰·W·图基,《探索性数据分析》(雷丁,马萨诸塞州:Addison-Wesley,1977 年)。

² 哈德利·维克姆,《整洁数据》,《统计软件杂志》第 59 卷,第 10 期(2014 年):1–23,https://doi.org/10.18637/jss.v059.i10

³ 美国人口调查局,《国际数据数据库(IDB)》,最近更新于 2020 年 12 月,https://www.census.gov/data-tools/demo/idb

第三章:时间序列分析

现在我已经讲解了 SQL 和数据库以及准备数据分析的关键步骤,是时候转向可以使用 SQL 进行的具体分析类型了。世界上似乎有无数的数据集,因此它们可以分析的方式也几乎是无限的。在本章节和接下来的章节中,我将把分析类型组织成主题,希望这些主题能帮助您建立分析和 SQL 技能。许多将要讨论的技术是基于第二章和随着本书进展的前几章展示的技术。数据的时间序列如此普遍且重要,以至于我将从这里开始讨论分析主题系列。

时间序列分析是使用 SQL 进行的最常见的分析类型之一。时间序列 是按时间顺序记录的一系列测量或数据点,通常是在定期间隔。日常生活中有许多时间序列数据的例子,如每日最高温度、标准普尔 500 股票指数的收盘价,或者您的健身追踪器记录的每日步数。时间序列分析广泛应用于各行各业,从统计学和工程学到天气预报和业务规划。时间序列分析是理解和量化事物随时间变化的一种方法。

预测是时间序列分析的一个常见目标。由于时间只会向前推进,未来的值可以表示为过去值的函数,反之则不成立。然而,重要的是要注意,过去并不能完美预测未来。任何广泛市场条件、流行趋势、产品推出或其他重大变化的变动都会使预测变得困难。尽管如此,查看历史数据可以带来洞察,并开发一系列合理的结果对计划非常有用。在我撰写本文时,全球正处于一场前所未有的 COVID-19 大流行中,这样的情况已有 100 年未见——早于所有但最长寿的组织历史。因此,许多现有的组织尚未经历过这种特定事件,但它们已经经历了其他经济危机,比如 2001 年的互联网泡沫破裂和 9/11 袭击,以及 2007 年至 2008 年的全球金融危机。通过仔细分析和理解背景,我们通常可以提取有用的见解。

在本章中,我们将首先介绍时间序列分析的 SQL 基本构建块:用于处理日期、时间戳和时间的语法和函数。接下来,我将介绍本章后续示例中使用的零售销售数据集。随后是趋势分析方法的讨论,然后是计算滚动时间窗口。接下来是周期对比计算,用于分析具有季节性成分的数据。最后,我们将介绍一些其他对时间序列分析有用的技术。

日期、日期时间和时间操作

日期和时间以多种格式呈现,取决于数据源。我们经常需要或希望转换原始数据格式以便输出,或者执行计算以得出新的日期或日期部分。例如,数据集可能包含交易时间戳,但分析的目标是趋势分析月度销售。其他时候,我们可能想知道自特定事件以来经过了多少天或多少个月。幸运的是,SQL 具有强大的函数和格式化功能,可以将几乎任何原始输入转换为我们分析所需的几乎任何输出。

在这一部分,我将向您展示如何在不同时区之间转换,并深入讨论日期和日期时间的格式化。接下来,我将探讨日期数学和时间操作,包括使用时间间隔的操作。时间间隔是一种数据类型,表示一段时间,如几个月、几天或几小时。虽然数据可以存储在数据库表中作为时间间隔类型,但在实际操作中我很少见到这样做,因此我会在讨论日期和时间函数时同时介绍时间间隔。最后,我将讨论从不同数据源联接或组合数据时的一些特殊考虑因素。

时区转换

理解数据集中使用的标准时区可以防止在分析过程的后期出现误解和错误。时区将世界分为观察同一时间的南北地区。时区允许世界上不同地区在白天和黑夜拥有类似的时钟时间,因此无论您身处世界何处,太阳在正午时都会在头顶上。时区遵循不规则的边界,这些边界既有政治性质也有地理性质。大多数时区相隔一小时,但有些只相隔 30 或 45 分钟,全球共有 30 多个时区。许多远离赤道的国家在一年中的部分时间内采用夏令时,但也有例外,如美国和澳大利亚,其中一些州采用夏令时,而其他州则不采用。每个时区都有一个标准缩写,例如太平洋标准时间的 PST 和太平洋夏令时间的 PDT。

许多数据库设置为协调世界时(UTC),这是用于调节时钟的全球标准,并在此时区记录事件。它取代了格林威治标准时间(GMT),如果您的数据来自旧数据库,您可能仍然会看到 GMT。UTC 没有夏令时,因此整年保持一致。这对分析非常有用。我记得有一次,一位惊慌失措的产品经理让我找出为什么某个特定星期日的销售额与前一个星期日相比下降这么多。我花了几个小时编写查询和调查可能的原因,最终发现我们的数据记录在太平洋时间(PT)中。夏令时在周日凌晨开始,数据库时钟向前移动了 1 小时,这一天只有 23 小时而不是 24 小时,因此销售额似乎下降了。半年后,我们经历了一个对应的 25 小时的日子,销售额看起来异常高。

注意

数据库中的时间戳通常没有编码时区信息,您需要与数据源或开发人员协商以确定数据的存储方式。我见过的数据集中,UTC 已经成为最常见的时间表示方式,但这并不是普遍适用的。

UTC 或任何机器时间记录的一个缺点是,我们丢失了生成数据库记录事件的人类操作的本地时间信息。我可能想知道人们在工作日还是在夜间和周末更频繁地使用我的移动应用。如果我的受众集中在一个时区内,那么弄清楚这一点并不难。但如果受众跨越多个时区或国际范围,那么将每个记录的时间转换为其本地时区就成为一个计算任务。

所有本地时区都有一个 UTC 偏移量。例如,PDT 的偏移量是 UTC - 7 小时,而 PST 的偏移量是 UTC - 8 小时。数据库中的时间戳以 YYYY-MM-DD hh:mi:ss 格式存储(年-月-日 时:分:秒)。带有时区的时间戳有一个额外的信息块,表示为正数或负数的 UTC 偏移量。可以使用 at time zone 后跟目标时区的缩写来实现从一个时区到另一个时区的转换。例如,我们可以将 UTC 中的时间戳(偏移量 - 0)转换为 PST:

SELECT '2020-09-01 00:00:00 -0' at time zone 'pst';

timezone
-------------------
2020-08-31 16:00:00

目标时区名称可以是常量,也可以是数据库字段,从而使此转换对数据集动态可行。一些数据库有类似的 convert_timezoneconvert_tz 函数。一个参数是结果的时区,另一个参数是要从中转换的时区:

SELECT convert_timezone('pst','2020-09-01 00:00:00 -0');

timezone
-------------------
2020-08-31 16:00:00

请查阅您数据库的文档,获取目标时区和源时间戳参数的确切名称和排序方式。许多数据库包含一个时区及其缩写的列表在一个系统表中。一些常见的见于 表 3-1。您可以通过SELECT * FROM表名进行查询。维基百科还有一个有用的列表,标准时区缩写和它们的 UTC 偏移量,可以在这里查看。

表 3-1. 常见数据库中的时区信息系统表

PostgreSQL pg_timezone_names
MySQL mysql.time_zone_names
SQL Server sys.time_zone_info
Redshift pg_timezone_names

时区是处理时间戳的固有部分。使用时区转换函数,可以在记录数据的时区和其他任何世界时区之间进行移动。接下来,我将向您展示几种使用 SQL 操作日期和时间戳的技巧。

日期和时间戳格式转换

日期和时间戳对于时间序列分析至关重要。由于源数据中日期和时间的表示方式多种多样,几乎不可避免地需要在某些时候进行日期格式转换。在本节中,我将介绍几种常见的转换方式,并演示如何在 SQL 中完成这些操作:改变数据类型、提取日期或时间戳的部分,以及根据部分创建日期或时间戳。首先,我将介绍一些有用的函数,它们可以返回当前日期和/或时间。

返回当前日期或时间是常见的分析任务,例如为结果集包含一个时间戳或在下一节中使用日期数学。当前日期和时间称为系统时间,使用 SQL 返回它们很容易,但不同数据库之间存在一些语法差异。

要返回当前日期,一些数据库具有current_date函数,无需括号:

SELECT current_date;

有更多种类的函数用于返回当前日期和时间。请查阅您数据库的文档,或直接在 SQL 窗口中尝试输入以查看函数是否返回值或错误。带有括号的函数不接受参数,但是包括括号是很重要的:

current_timestamp
localtimestamp
get_date()
now()

最后,有些函数只返回当前系统时间的时间戳部分。再次查阅文档或实验,以确定在您的数据库中使用哪些函数:

current_time
localtime
timeofday()

SQL 具有许多用于改变日期和时间格式的函数。要减少时间戳的粒度,请使用date_trunc函数。第一个参数是指示在第二个参数中截断时间戳的时间段级别的文本值。结果是一个时间戳值:

date_trunc (text, timestamp)

SELECT date_trunc('month','2020-10-04 12:33:35'::timestamp);

date_trunc
-------------------
2020-10-01 00:00:00

可以使用的标准参数列在表 3-2 中。这些参数的范围从微秒到千年,提供了充分的灵活性。不支持date_trunc的数据库(如 MySQL)有一个名为date_format的替代函数,可以以类似的方式使用:

SELECT date_format('2020-10-04 12:33:35','%Y-%m-01') as date_trunc;

date_trunc
-------------------
2020-10-01 00:00:00

表 3-2. 标准时间段参数

时间段参数
微秒
毫秒
分钟
小时
季度
十年
世纪
千年

有时,我们的分析不是返回日期或时间戳,而是需要返回日期或时间的部分。例如,我们可能希望按月、星期几或每天的小时来分组销售数据。

SQL 提供了一些函数,只返回所需的日期或时间戳部分。日期和时间戳通常是可以互换的,除非请求返回的是时间部分。在这种情况下,当然是需要时间的。

date_part函数接受一个文本值来返回指定的部分以及一个日期或时间戳值。返回值是一个 FLOAT,这是一个带有小数部分的数值;根据您的需求,您可能希望将该值转换为整数数据类型:

SELECT date_part('day',current_timestamp);
SELECT date_part('month',current_timestamp);
SELECT date_part('hour',current_timestamp);

另一个类似的函数是extract,它接受一个部分名称和一个日期或时间戳值,并返回一个 FLOAT 值:

SELECT extract('day' from current_timestamp);

date_part
---------
27.0

SELECT extract('month' from current_timestamp);

date_part
---------
5.0

SELECT extract('hour' from current_timestamp);

date_part
---------
14.0

函数date_partextract可以与间隔一起使用,但请注意,请求的部分必须与间隔的单位匹配。因此,例如,从以天表示的间隔中请求天数将返回预期值 30:

SELECT date_part('day',interval '30 days');

SELECT extract('day' from interval '30 days');

date_part
---------
30.0

但是,从以月表示的间隔中请求天数将返回值 0.0:

SELECT extract('day' from interval '3 months');

date_part
---------
0.0
注意

您可以在数据库的文档或在线搜索中找到日期部分的完整列表,但一些最常见的包括日期的“day”、“month”和“year”,以及时间戳的“second”、“minute”和“hour”。

要返回日期部分的文本值,请使用to_char函数,该函数接受输入值和输出格式作为参数:

SELECT to_char(current_timestamp,'Day');
SELECT to_char(current_timestamp,'Month');
提示

如果您曾经遇到以 Unix 时间戳存储的时间戳(自 1970 年 1 月 1 日 00:00:00 UTC 以来经过的秒数),您可以使用to_timestamp函数将其转换为时间戳。

有时分析需要从不同来源的部分创建日期。当年、月和日的值存储在数据库的不同列中时,就会发生这种情况。当从文本中解析出部分内容时,也可能需要这样做,这是我将在第五章中更深入讨论的一个主题。

从单独的日期和时间组件创建时间戳的简单方法是使用加号(+)将它们连接在一起:

SELECT date '2020-09-01' + time '03:00:00' as timestamp;

timestamp 
-------------------
2020-09-01 03:00:00

可以使用make_datemakedatedate_from_partsdatefromparts函数来组装日期。这些函数等效,但不同的数据库使用不同的函数名称。该函数接受年、月和日部分的参数,并返回具有日期格式的值:

SELECT make_date(2020,09,01);

make_date
----------
2020-09-01

参数可以是常量或参考字段名,并且必须是整数。组装日期或时间戳的另一种方法是将值连接在一起,然后使用一个转换语法或 to_date 函数将结果转换为日期格式:

SELECT to_date(concat(2020,'-',09,'-',01), 'yyyy-mm-dd');

to_date
----------
2020-09-01

SELECT cast(concat(2020,'-',09,'-',01) as date);

to_date
----------
2020-09-01

SQL 有多种方法可以格式化和转换日期和时间戳,获取系统日期和时间。在下一节中,我将开始在日期数学中应用它们。

日期数学

SQL 允许我们对日期执行各种数学运算。这可能令人惊讶,因为严格来说,日期不是数值数据类型,但如果您曾尝试过计算四周后的日期,这个概念应该是熟悉的。日期数学对各种分析任务都很有用。例如,我们可以用它来找出客户的年龄或任职时间,两个事件之间经过了多少时间,以及在时间窗口内发生了多少事件。

日期数学涉及两种类型的数据:日期本身和间隔。我们需要间隔的概念,因为日期和时间组件的行为不像整数。100 的十分之一是 10;一年的十分之一是 36.5 天。100 的一半是 50;一天的一半是 12 小时。间隔允许我们在时间单位之间平滑移动。间隔有两种类型:年-月间隔和日-时间间隔。我们将从返回整数值的几个操作开始,然后转向处理或返回间隔的函数。

首先,让我们找到两个日期之间的天数差。在 SQL 中有几种方法可以实现这一点。第一种方法是使用数学运算符,减号(–):

SELECT date('2020-06-30') - date('2020-05-31') as days;

days
----
30

这将返回这两个日期之间的天数。请注意,答案是 30 天而不是 31 天。天数包括了其中一个端点。反向减去日期也可以工作,并返回一个间隔为–30 天:

SELECT date('2020-05-31') - date('2020-06-30') as days;

days
----
-30

使用 datediff 函数也可以找到两个日期之间的差异。Postgres 不支持它,但许多其他流行的数据库支持,包括 SQL Server、Redshift 和 Snowflake,它非常方便,特别是当目标不仅仅是返回天数时。该函数接受三个参数——要返回的时间段单位,起始时间戳或日期,以及结束时间戳或日期:

datediff(interval_name, start_timestamp, end_timestamp)

因此,我们之前的例子看起来像这样:

SELECT datediff('day',date('2020-05-31'), date('2020-06-30')) as days;

days
----
30

我们还可以找到两个日期之间的月份数,尽管一年中的月份长度有所不同,数据库会进行正确的计算:

SELECT datediff('month'
                ,date('2020-01-01')
                ,date('2020-06-30')
                ) as months;

months
------
5

在 Postgres 中,可以使用 age 函数来实现这一点,它计算两个日期之间的间隔:

SELECT age(date('2020-06-30'),date('2020-01-01'));

age
--------------
5 mons 29 days

我们然后可以使用 date_part() 函数找到时间间隔的月份组成部分:

SELECT date_part('month',age('2020-06-30','2020-01-01')) as months;

months
------
5.0

在两个日期之间减去以找到经过的时间是非常强大的。通过添加日期无法以相同的方式工作。要对日期进行加法运算,我们需要利用间隔或特殊函数。

例如,我们可以通过添加间隔'7 days'来将七天添加到日期中:

SELECT date('2020-06-01') + interval '7 days' as new_date;

new_date
-------------------
2020-06-08 00:00:00

一些数据库不需要间隔语法,而是自动将提供的数字转换为天数,尽管通常最好使用间隔表示法,这样既可跨数据库兼容,又能使代码更易读:

SELECT date('2020-06-01') + 7 as new_date;

new_date
-------------------
2020-06-08 00:00:00

如果要添加不同单位的时间,请使用月份、年份、小时或其他日期或时间段的间隔表示法。请注意,这也可以用于通过使用“-”而不是“+”从日期中减去间隔。许多但不是所有的数据库都有一个date_adddateadd函数,该函数接受所需的间隔、值和起始日期,并进行数学计算。

SELECT date_add('month',1,'2020-06-01') as new_date;

new_date
----------
2020-07-01
提示

请参考数据库的文档,或者尝试查询,以找出适合项目的语法和函数。

以上任何一种表述都可以用于WHERE子句,除了SELECT子句。例如,我们可以筛选出至少发生在三个月前的记录:

WHERE event_date < current_date - interval '3 months'

它们也可以用于JOIN条件,但请注意,如果JOIN条件包含计算而不是日期之间的相等或不等,数据库性能通常会较慢。

在 SQL 分析中使用日期数学是常见的,用于查找日期或时间戳之间经过的时间,并根据已知日期的间隔计算新日期。有几种方法可以找到两个日期之间经过的时间,将间隔添加到日期中,并从日期中减去间隔。接下来,我们将转向类似的时间操作。

时间数学

在许多分析领域中,时间数学不常见,但在某些情况下非常有用。例如,我们可能想知道一个支持代表在呼叫中心接听电话或回复请求协助的电子邮件需要多长时间。当两个事件之间的经过时间少于一天,或者将结果舍入到天数不提供足够信息时,时间操作变得重要。时间数学与日期数学类似,通过利用间隔来工作。我们可以将时间间隔添加到时间中:

SELECT time '05:00' + interval '3 hours' as new_time;

new_time
--------
08:00:00

我们可以从时间中减去间隔:

SELECT time '05:00' - interval '3 hours' as new_time;

new_time
--------
02:00:00

我们还可以相减时间,得到一个时间间隔:

SELECT time '05:00' - time '03:00' as time_diff;

time_diff
---------
02:00:00

与日期不同,时间可以相乘:

SELECT time '05:00' * 2 as time_multiplied;

time_multiplied
---------------
10:00:00

间隔也可以相乘,得到一个时间值:

SELECT interval '1 second' * 2000 as interval_multiplied;

interval_multiplied
-------------------
00:33:20

SELECT interval '1 day' * 45 as interval_multiplied;

interval_multiplied
-------------------
45 days

这些示例使用了常量值,但你也可以在 SQL 查询中包含数据库字段名称或计算,使计算变得动态。接下来,我将讨论在合并来自不同来源系统的数据集时需要考虑的特殊日期问题。

来自不同来源的数据连接

结合不同来源的数据是数据仓库的最具吸引力的应用之一。然而,不同的源系统可能会以不同的格式记录日期和时间,或者以不同的时区记录,甚至可能仅仅因为服务器内部时钟时间的问题而略有差异。即使来自同一数据源的表也可能存在差异,尽管这种情况较少见。在进一步分析之前,调和和标准化日期和时间戳是一个重要的步骤。

可以通过 SQL 来标准化不同格式的日期和时间戳。在日期上进行 JOIN 或在 UNION 中包含日期字段通常要求日期或时间戳具有相同的格式。在本章前面,我展示了一些处理日期和时间戳格式的技术,这些技术在处理这些问题时非常有用。在合并来自不同来源的数据时要特别注意时区问题。例如,内部数据库可能使用 UTC 时间,但来自第三方的数据可能使用本地时区。我曾看到来自软件即服务(SaaS)的数据记录在各种本地时间中。注意,时间戳的值本身不一定包含时区信息。您可能需要查阅供应商的文档并将数据转换为 UTC 时间,以便与存储在此方式的其他数据对接。另一个选项是在一个字段中存储时区信息,以便根据需要转换时间戳值。

处理来自不同来源的数据时,另一个需要注意的问题是时间戳略微不同步。当时间戳记录来自客户端设备时(例如来自一种数据源的笔记本电脑或移动电话和另一种数据源的服务器),这种情况可能会发生。我曾经看到一系列实验结果被错误计算,因为记录用户操作的客户端移动设备与记录用户分配的处理组的服务器的时间戳相差几分钟。来自移动客户端的数据似乎早于处理组时间戳到达,因此某些事件被无意中排除在外。这类问题的修复相对比较简单:不是过滤操作时间戳大于处理组时间戳的事件,而是允许在处理时间戳之前的一个短时间间隔或时间窗口内的事件包含在结果中。这可以通过 BETWEEN 子句和日期计算来实现,正如在最后一节所见。

在处理来自移动应用的数据时,特别注意时间戳是否表示事件在设备上发生的时间或者事件到达数据库的时间。这种差异可能从可以忽略不计一直到几天,这取决于移动应用是否允许离线使用以及在低信号强度期间如何处理发送数据。移动应用的数据可能会迟到,或者在设备上发生后数天才会进入数据库。日期和时间戳也可能在传输途中损坏,因此您可能会看到远在过去或未来的不可能的日期。

现在我已经展示了如何通过更改格式、转换时区、执行日期数学运算以及跨不同来源的数据集进行操作,我们已经准备好进入一些时间序列示例。首先,我将介绍本章其余部分示例数据集。

零售销售数据集

本章剩余部分的示例使用来自月度零售贸易报告:零售和食品服务销售:Excel(1992 年至今)的数据集,可在Census.gov 网站上找到。这份报告中的数据被用作经济指标,以了解美国消费者支出模式的趋势。尽管国内生产总值(GDP)数据每季度发布一次,但这些零售销售数据每月发布一次,因此它也被用来帮助预测 GDP。由于这两个原因,最新的数据通常在发布时被商业新闻报道。

数据跨越从 1992 年到 2020 年,并包括零售销售的子类别的详细信息。它包含未经调整和季节性调整的数字。本章将使用未经调整的数字,因为分析季节性是目标之一。销售数据以美元百万计。原始文件格式为 Excel 文件,每年一个标签,并以月份为列。这本书的 GitHub 站点提供了一种更容易导入数据库的数据格式,以及专门用于导入到 Postgres 的代码。图 3-1 展示了retail_sales表的样本。

图 3-1. 美国零售销售数据集预览

数据趋势

对于时间序列数据,我们通常希望查找数据的趋势。趋势只是数据移动的方向。它可能随时间上升或增加,也可能随时间下降或减少。它可能保持相对平坦,或者可能有很多噪声或上下波动,以至于很难确定趋势。本节将介绍几种用于趋势时间序列数据的技术,从简单趋势图到比较趋势组件,使用百分比总计来比较部分和整体,最后使用指数计算来查看与参考时间段的百分比变化。

简单趋势

创建趋势可能是描述和理解数据的步骤,也可能是最终的输出。结果集是一系列日期或时间戳和一个数值。在图示时间序列时,日期或时间戳将成为 x 轴,数值将成为 y 轴。例如,我们可以检查美国总零售和食品服务销售的趋势:

SELECT sales_month
,sales
FROM retail_sales
WHERE kind_of_business = 'Retail and food services sales, total'
;

sales_month  sales
-----------  ------
1992-01-01   146376
1992-02-01   147079
1992-03-01   159336
...          ...

结果在 图 3-2 中进行了图示。

图 3-2. 月度零售和食品服务销售趋势

这些数据显然有一些模式,但也有一些噪音。将数据转换并在年度水平进行汇总可以帮助我们更好地理解。首先,我们将使用 date_part 函数从 sales_month 字段中返回年份,然后对 sales 进行求和。结果在 WHERE 子句中筛选为“总体零售和食品服务销售”。

SELECT date_part('year',sales_month) as sales_year   
,sum(sales) as sales
FROM retail_sales
WHERE kind_of_business = 'Retail and food services sales, total'
GROUP BY 1
;

sales_year  sales
----------  -------
1992.0      2014102
1993.0      2153095
1994.0      2330235
...         ...

在像 图 3-3 中展示这些数据之后,我们现在有一个随时间逐渐增长的更平滑的时间序列,这是可以预期的,因为销售值没有进行通货膨胀调整。所有零售和食品服务的销售在全球金融危机期间的 2009 年下降。在整个 2010 年代的每年增长之后,由于 COVID-19 大流行的影响,2020 年的销售与 2019 年相比保持平稳。

图 3-3. 年度总零售和食品服务销售趋势

在不同级别(如每周、每月或每年)图示时间序列数据是了解趋势的好方法。此步骤可用于简单地描述数据,但也可以是最终输出,具体取决于分析的目标。接下来,我们将转向使用 SQL 来比较时间序列的组成部分。

比较各个组成部分

数据集通常不仅包含单一时间序列,还包含多个切片或总体的组成部分。比较这些切片通常会显示出有趣的模式。在零售销售数据集中,不仅有总销售额的数值,还有许多子类别。让我们比较与休闲活动相关的几个类别的年度销售趋势:书店、运动用品店和爱好店。此查询在SELECT子句中添加了kind_of_business,并且由于它是另一个属性而不是聚合,还将其添加到GROUP BY子句中。

SELECT date_part('year',sales_month) as sales_year
,kind_of_business
,sum(sales) as sales
FROM retail_sales
WHERE kind_of_business in ('Book stores'
 ,'Sporting goods stores','Hobby, toy, and game stores')
GROUP BY 1,2
;

sales_year  kind_of_business             sales
----------  ---------------------------  -----
1992.0      Book stores                  8327
1992.0      Hobby, toy, and game stores  11251
1992.0      Sporting goods stores        15583
...         ...                          ...

结果显示在图 3-4 中。运动用品零售商的销售在三类别中起始最高,并在时间段内增长迅速,到时间序列结束时,这些销售额显著更高。运动用品店的销售从 2017 年开始下降,但在 2020 年有了大幅反弹。玩具和游戏店的销售在此期间相对稳定,中期有轻微下降,2020 年前有另一轻微下降后又反弹。书店的销售在 2000 年代中期之前一直增长,此后一直在下降。所有这些类别都受到在线零售商的影响,但时间和影响程度似乎不同。

图 3-4. 运动用品店,玩具和游戏店以及书店的年度零售销售趋势

除了简单的趋势分析外,我们可能还希望在时间序列的各个部分之间进行更复杂的比较。在接下来的几个例子中,我们将分析女装店和男装店的销售情况。请注意,由于名称中包含撇号,这是字符串开头和结尾的指示字符,我们需要用额外的撇号对其进行转义。这样数据库才能知道撇号是字符串的一部分而不是结尾。尽管我们可能考虑在数据加载管道中添加一个步骤,以删除名称中的额外撇号,但我已将其保留在这里,以展示现实世界中通常需要进行的代码调整类型。首先,我们将按月份趋势显示每种类型商店的数据:

SELECT sales_month
,kind_of_business
,sales
FROM retail_sales
WHERE kind_of_business in ('Men''s clothing stores'
 ,'Women''s clothing stores')
;

sales_month  kind_of_business         sales
-----------  -----------------------  -----
1992-01-01   Men's clothing stores    701
1992-01-01   Women's clothing stores  1873
1992-02-01   Women's clothing stores  1991
...          ...                      ...

结果显示在图 3-5 中。女装零售商的销售额远高于男装零售商。这两种类型的商店都表现出季节性,这是我将在“分析季节性”中深入讨论的一个主题。由于 2020 年的店铺关闭和 COVID-19 大流行导致购物减少,两者在 2020 年经历了显著下降。

图 3-5. 女装和男装店销售的月度趋势

月度数据显示出有趣的模式,但也有噪音,因此在接下来的几个示例中我们将使用年度聚合数据。我们之前见过这种查询格式,用于汇总总销售额和休闲类别的销售:

SELECT date_part('year',sales_month) as sales_year
,kind_of_business
,sum(sales) as sales
FROM retail_sales
WHERE kind_of_business in ('Men''s clothing stores'
 ,'Women''s clothing stores')
GROUP BY 1,2
;

女装店铺的销售额是否一直高于男装店铺?如图 Figure 3-6 所示的年度趋势,男装和女装的销售差距似乎并不是恒定的,而是在 2000 年代初期到中期逐渐扩大。尤其是在 2008 年至 2009 年的全球金融危机期间,女装销售量明显下降,而 2020 年的疫情期间两个类别的销售都大幅下降。

图 3-6. 女装和男装店铺销售的年度趋势

然而,我们不需要依赖视觉估计。为了更精确地了解这一差距,我们可以计算两个类别之间的差距、比率以及百分比差异。为此,第一步是调整数据,使每个月份都有一行数据,每个类别都有一个列。使用聚合函数结合 CASE 语句来逆转数据可以实现这一点:

SELECT date_part('year',sales_month) as sales_year
,sum(case when kind_of_business = 'Women''s clothing stores' 
          then sales 
          end) as womens_sales
,sum(case when kind_of_business = 'Men''s clothing stores' 
          then sales 
          end) as mens_sales
FROM retail_sales
WHERE kind_of_business in ('Men''s clothing stores'
 ,'Women''s clothing stores')
GROUP BY 1
;

sales_year  womens_sales  mens_sales
----------  ------------  ----------
1992.0      31815         10179
1993.0      32350         9962
1994.0      30585         10032
...         ...           ...

利用这个基础计算模块,我们可以找到数据集中时间序列之间的差异、比率和百分比差异。差异可以通过数学运算符“-”相减来计算。根据分析的目标,从男装销售额或女装销售额中找到差异可能都是合适的。这里展示了两者的等价性,除了符号的不同:

SELECT sales_year
,womens_sales - mens_sales as womens_minus_mens
,mens_sales - womens_sales as mens_minus_womens
FROM
(
    SELECT date_part('year',sales_month) as sales_year
    ,sum(case when kind_of_business = 'Women''s clothing stores' 
              then sales 
              end) as womens_sales
    ,sum(case when kind_of_business = 'Men''s clothing stores' 
              then sales 
              end) as mens_sales
    FROM retail_sales
    WHERE kind_of_business in ('Men''s clothing stores'
     ,'Women''s clothing stores')
    and sales_month <= '2019-12-01'
    GROUP BY 1
) a
;

sales_year  womens_minus_mens  mens_minus_womens
----------  -----------------  -----------------
1992.0      21636              -21636
1993.0      22388              -22388
1994.0      20553              -20553
...         ...                ...

从查询执行的角度来看,子查询并不是必需的,因为可以将聚合函数添加到或从彼此中减去。子查询通常更易读,但会增加代码的行数。根据 SQL 查询的长度或复杂性,您可能更喜欢将中间计算放在子查询中,或者直接在主查询中计算。这是一个没有子查询的示例,从女装销售中减去男装销售,并增加了一个WHERE子句过滤器,以移除 2020 年的空值月份:¹

SELECT date_part('year',sales_month) as sales_year
,sum(case when kind_of_business = 'Women''s clothing stores' 
          then sales end) 
 - 
 sum(case when kind_of_business = 'Men''s clothing stores' 
          then sales end)
 as womens_minus_mens
FROM retail_sales
WHERE kind_of_business in ('Men''s clothing stores'
 ,'Women''s clothing stores')
and sales_month <= '2019-12-01'
GROUP BY 1
;

sales_year  womens_minus_mens
----------  -----------------
1992.0      21636
1993.0      22388
1994.0      20553
...         ...

图 3-7. 女装和男装店铺销售差异的年度变化

图 3-7. 女装和男装店铺销售差异的年度变化

让我们继续调查并观察这些类别之间的比率。我们将使用男装销售作为基线或分母,但请注意我们也可以轻松地使用女装店铺销售:

SELECT sales_year
,womens_sales / mens_sales as womens_times_of_mens
FROM
(
    SELECT date_part('year',sales_month) as sales_year
    ,sum(case when kind_of_business = 'Women''s clothing stores' 
              then sales 
              end) as womens_sales
    ,sum(case when kind_of_business = 'Men''s clothing stores' 
              then sales 
              end) as mens_sales
    FROM retail_sales
    WHERE kind_of_business in ('Men''s clothing stores'
     ,'Women''s clothing stores')
    and sales_month <= '2019-12-01'
    GROUP BY 1
) a
;

sales_year  womens_times_of_mens
----------  --------------------
1992.0      3.1255526083112290
1993.0      3.2473398915880345
1994.0      3.0487440191387560
...         ...
提示

在进行除法运算时,SQL 返回大量小数位。在呈现分析结果之前,通常考虑对结果进行四舍五入处理。使用合适的精度(小数位数)来讲述故事。

绘制结果,如图 3-8 所示,显示出趋势与差异趋势相似,但在 2009 年差异减少时,比率实际上增加了。

图 3-8. 每年女装销售与男装销售的比率

接下来,我们可以计算女装店和男装店销售之间的百分比差异:

SELECT sales_year
,(womens_sales / mens_sales - 1) * 100 as womens_pct_of_mens
FROM
(
    SELECT date_part('year',sales_month) as sales_year
    ,sum(case when kind_of_business = 'Women''s clothing stores' 
              then sales 
              end) as womens_sales
    ,sum(case when kind_of_business = 'Men''s clothing stores' 
              then sales 
              end) as mens_sales
    FROM retail_sales
    WHERE kind_of_business in ('Men''s clothing stores'
     ,'Women''s clothing stores')
    and sales_month <= '2019-12-01'
    GROUP BY 1
) a
;

sales_year  womens_pct_of_mens
----------  --------------------
1992.0      212.5552608311229000
1993.0      224.7339891588034500
1994.0      204.8744019138756000
...         ...

尽管此输出的单位与前面示例中的单位不同,但此图的形状与比率图相同。选择使用哪种取决于您的受众和您领域的规范。所有这些声明都是准确的:在 2009 年,女装店的销售比男装店高 287 亿美元;在 2009 年,女装店的销售是男装店销售的 4.9 倍;在 2009 年,女装店的销售比男装店销售高 390%。选择哪个版本取决于您希望通过分析讲述的故事。

本节中所见的转换允许我们通过比较相关部分来分析时间序列。下一节将继续通过展示如何分析代表整体部分的系列来比较时间序列。

百分比总计算

当处理具有多个部分或属性构成整体的时间序列数据时,通常有必要分析每个部分对整体的贡献以及这种贡献是否随时间变化。除非数据已经包含总值的时间序列,否则我们需要计算总体总值,以便计算每行的总百分比。这可以通过自我-JOIN或窗口函数来实现,正如我们在第二章中所见,窗口函数是一种特殊的 SQL 函数,可以引用表中指定分区内的任何行。

首先我将展示自我-JOIN方法。自我-JOIN是指表与自身连接的任何时刻。只要查询中表的每个实例都被赋予不同的别名,数据库将把它们都视为不同的表。例如,为了找出每个系列表示的男装和女装总销售的百分比,我们可以将retail_sales作为a别名与retail_sales作为b别名进行JOIN,并在sales_month字段上。然后从别名aSELECT出单个系列名称(kind_of_business)和sales值。然后,从别名b中对两个类别的销售求和,并将结果命名为total_sales。请注意,表之间在sales_month字段上的JOIN创建了部分笛卡尔JOIN,导致别名a的每一行对应别名b的两行。通过按a.sales_montha.kind_of_businessa.sales进行分组,并对b.sales进行聚合,确切返回所需的结果。在外部查询中,每行的百分比总数通过将sales除以total_sales来计算:

SELECT sales_month
,kind_of_business
,sales * 100 / total_sales as pct_total_sales
FROM
(
    SELECT a.sales_month, a.kind_of_business, a.sales
    ,sum(b.sales) as total_sales
    FROM retail_sales a
    JOIN retail_sales b on a.sales_month = b.sales_month
    and b.kind_of_business in ('Men''s clothing stores'
     ,'Women''s clothing stores')
    WHERE a.kind_of_business in ('Men''s clothing stores'
     ,'Women''s clothing stores')
    GROUP BY 1,2,3
) aa
;

sales_month  kind_of_business         pct_total_sales
-----------  -----------------------  -------------------
1992-01-01   Men's clothing stores    27.2338772338772339
1992-01-01   Women's clothing stores  72.7661227661227661
1992-02-01   Men's clothing stores    24.8395620989052473
...          ...                      ...

在这里并不需要子查询,因为可以在没有它的情况下获得相同的结果,但是它使代码稍微容易理解一些。另一种计算每个类别总销售额百分比的方法是使用sum窗口函数并按sales_month进行PARTITION BY。请记住,PARTITION BY子句指示函数应计算的表中的部分。在这种sum窗口函数中不需要ORDER BY子句,因为计算的顺序并不重要。此外,查询不需要GROUP BY子句,因为窗口函数跨多行查看数据,但它们不会减少结果集中的行数:

SELECT sales_month, kind_of_business, sales
,sum(sales) over (partition by sales_month) as total_sales
,sales * 100 / sum(sales) over (partition by sales_month) as pct_total
FROM retail_sales 
WHERE kind_of_business in ('Men''s clothing stores'
 ,'Women''s clothing stores')
;

sales_month  kind_of_business         sales  total_sales  pct_total
-----------  -----------------------  -----  -----------  ---------
1992-01-01   Men's clothing stores    701    2574         27.233877
1992-01-01   Women's clothing stores  1873   2574         72.766122
1992-02-01   Women's clothing stores  1991   2649         75.160437
...          ...                      ...    ...          ...

图表化这些数据,如图 3-9,显示了一些有趣的趋势。首先,从 20 世纪 90 年代末开始,女装店销售额占总销售额的比例逐渐增加。其次,在系列初期可以看到明显的季节性模式,其中男装在 12 月和 1 月的销售额作为总销售额的百分比出现了峰值。在 21 世纪的第一个十年中,出现了两个季节性高峰,分别是夏季和冬季,但到 2010 年代末,季节性模式几乎消失,几乎变得随机。我们将在本章后面更深入地分析季节性。

图 3-9. 男装和女装店销售额占月度总额的百分比

另一个我们可能想要找到的百分比是在较长时间段内的销售额百分比,例如每个月占年度销售额的百分比。在这种情况下,可以使用自连接(self-JOIN)或窗口函数来完成。在这个例子中,我们将在子查询中使用自连接(self-JOIN):

SELECT sales_month
,kind_of_business
,sales * 100 / yearly_sales as pct_yearly
FROM
(
    SELECT a.sales_month, a.kind_of_business, a.sales
    ,sum(b.sales) as yearly_sales
    FROM retail_sales a
    JOIN retail_sales b on 
     date_part('year',a.sales_month) = date_part('year',b.sales_month)
     and a.kind_of_business = b.kind_of_business
     and b.kind_of_business in ('Men''s clothing stores'
      ,'Women''s clothing stores')
    WHERE a.kind_of_business in ('Men''s clothing stores'
     ,'Women''s clothing stores')
    GROUP BY 1,2,3
) aa
;

sales_month  kind_of_business       pct_yearly
-----------  ---------------------  ------------------
1992-01-01   Men's clothing stores  6.8867275763827488
1992-02-01   Men's clothing stores  6.4642892229099126
1992-03-01   Men's clothing stores  7.1814520090382159
...          ...                    ...

或者,可以使用窗口函数方法:

SELECT sales_month, kind_of_business, sales
,sum(sales) over (partition by date_part('year',sales_month)
                               ,kind_of_business
                               ) as yearly_sales
,sales * 100 / 
 sum(sales) over (partition by date_part('year',sales_month)
                               ,kind_of_business
                               ) as pct_yearly
FROM retail_sales 
WHERE kind_of_business in ('Men''s clothing stores'
 ,'Women''s clothing stores')
;

sales_month  kind_of_business       pct_yearly
-----------  ---------------------  ------------------
1992-01-01   Men's clothing stores  6.8867275763827488
1992-02-01   Men's clothing stores  6.4642892229099126
1992-03-01   Men's clothing stores  7.1814520090382159
...          ...                    ...

缩小至 2019 年的结果如图 3-10 所示。这两个时间序列基本上是紧密跟踪的,但男装店在 1 月的销售额比女装店更高。男装店在 7 月有一个夏季低谷,而女装店的对应低谷则直到 9 月才出现。

图 3-10. 2019 年女装和男装销售额占年度销售额的百分比

现在我已经展示了如何使用 SQL 进行总计百分比的计算以及可以完成的分析类型,接下来我将转向索引和计算随时间变化的百分比变化。

索引以查看随时间变化的百分比变化

时间序列中的值通常随时间波动。销售随产品的流行度和可用性增长而增加,而网页响应时间则随工程师优化代码的努力而减少。索引数据是理解时间序列相对于基期(起始点)变化的一种方式。指数在经济学以及商业环境中被广泛使用。最著名的指数之一是消费者价格指数(CPI),它跟踪典型消费者购买的物品价格变化,并用于跟踪通货膨胀、决定工资增长等多种应用。CPI 是一个复杂的统计指标,使用各种权重和数据输入,但其基本原理很简单。选择一个基期,并计算从该基期开始每个后续期间的值的百分比变化。

使用 SQL 索引时间序列数据可以通过聚合和窗口函数,或者自连接来完成。例如,我们将女装店销售与系列的第一年 1992 年索引。第一步是在子查询中按sales_year聚合sales,正如我们之前所做的那样。在外部查询中,first_value窗口函数根据ORDER BY子句中的排序,在PARTITION BY子句中找到与第一行关联的值。在这个例子中,我们可以省略PARTITION BY子句,因为我们想要返回由子查询返回的整个数据集中的第一行的销售value

SELECT sales_year, sales
,first_value(sales) over (order by sales_year) as index_sales
FROM
(
    SELECT date_part('year',sales_month) as sales_year
    ,sum(sales) as sales
    FROM retail_sales
    WHERE kind_of_business = 'Women''s clothing stores'
    GROUP BY 1
) a
;

sales_year  sales  index_sales
----------  -----  -----------
1992.0      31815  31815
1993.0      32350  31815
1994.0      30585  31815
...         ...    ...

通过这个数据样本,我们可以直观地验证索引值是否正确地设置在 1992 年的值上。接下来,找到每一行相对于这个基准年的百分比变化:

SELECT sales_year, sales
,(sales / first_value(sales) over (order by sales_year) - 1) * 100 
 as pct_from_index
FROM
(
    SELECT date_part('year',sales_month) as sales_year
    ,sum(sales) as sales
    FROM retail_sales
    WHERE kind_of_business = 'Women''s clothing stores'
    GROUP BY 1
) a
;

sales_year  sales  pct_from_index
----------  -----  --------------
1992.0      31815  0
1993.0      32350  1.681596731101
1994.0      30585  -3.86610089580
...         ...    ...

百分比变化可以是正数也可以是负数,我们将看到这种情况确实发生在这个时间序列中。在此查询中,可以用first_value替换last_value窗口函数。从系列的最后一个值开始索引远不常见,然而,由于分析问题更经常与从一个任意结束点向后查看有关,而不是从一个起始点开始,这种选择仍然存在。另外,排序顺序可以用来实现从第一个或最后一个值进行索引,通过在ASCDESC之间切换:

first_value(sales) over (order by sales_year desc)

窗口函数提供了很大的灵活性。可以通过一系列自连接来完成索引,尽管需要更多的代码行数:

SELECT sales_year, sales
,(sales / index_sales - 1) * 100 as pct_from_index
FROM
(
    SELECT date_part('year',aa.sales_month) as sales_year
    ,bb.index_sales
    ,sum(aa.sales) as sales
    FROM retail_sales aa
    JOIN 
    (
        SELECT first_year, sum(a.sales) as index_sales
        FROM retail_sales a
        JOIN 
        (
            SELECT min(date_part('year',sales_month)) as first_year
            FROM retail_sales
            WHERE kind_of_business = 'Women''s clothing stores'
        ) b on date_part('year',a.sales_month) = b.first_year 
        WHERE a.kind_of_business = 'Women''s clothing stores'
        GROUP BY 1
    ) bb on 1 = 1
    WHERE aa.kind_of_business = 'Women''s clothing stores'
    GROUP BY 1,2
) aaa
;

sales_year  sales  pct_from_index
----------  -----  --------------
1992.0      31815  0
1993.0      32350  1.681596731101
1994.0      30585  -3.86610089580
...         ...    ...

注意到在别名aa和子查询bb之间的不寻常的JOIN子句on 1 = 1。由于我们希望index_sales值填充结果集中的每一行,我们不能根据年份或任何其他值进行JOIN,因为这将限制结果。然而,如果未指定JOIN子句,数据库将返回错误。我们可以通过使用任何计算结果为 TRUE 的表达式来愚弄数据库,以创建所需的笛卡尔JOIN。任何其他 TRUE 语句,比如on 2 = 2on 'apples' = 'apples',都可以使用。

警告

在最后一个示例中,例如 sales / index_sales,请注意分母中的零。当数据库遇到除以零时,会返回错误,这可能很令人沮丧。即使你认为分母字段中的零不太可能出现,也最好通过告诉数据库在遇到零时返回备用默认值的方式来预防这种情况。可以通过 CASE 语句实现这一点。本节的示例中分母没有零,为了可读性,我将省略额外的代码。

最后,让我们看一下男装店和女装店索引时间序列的图表,如图 3-11 所示。SQL 代码如下:

SELECT sales_year, kind_of_business, sales
,(sales / first_value(sales) over (partition by kind_of_business 
                                   order by sales_year)
 - 1) * 100 as pct_from_index
FROM
(
    SELECT date_part('year',sales_month) as sales_year
    ,kind_of_business
    ,sum(sales) as sales
    FROM retail_sales
    WHERE kind_of_business in ('Men''s clothing stores'
     ,'Women''s clothing stores')
    and sales_month <= '2019-12-31'
    GROUP BY 1,2
) a
;

图 3-11。男装店和女装店销售,以 1992 年的销售为基准

从这张图表可以明显看出,1992 年对于男装店的销售来说是一个高点。1992 年后销售下降,然后在 1998 年短暂回到同一水平,此后一直在下降。这一点很显著,因为数据集没有调整为不考虑通货膨胀,即随着时间推移价格上涨的趋势。女装店的销售从 1992 年开始也有所下降,但到了 2003 年又回到了 1992 年的水平。此后销售有所增加,除了 2009 年和 2010 年金融危机期间的销售下降。这些趋势的一个解释可能是,随着时间推移,男性简单地减少了对服装的支出,相对于女性来说可能对时尚不那么关注。也许男装变得更便宜,因为全球供应链降低了成本。还有一个解释可能是,男性将他们的服装购买从分类为“男装店”的零售商转移到了其他类型的零售商,如体育用品店或在线零售商。

将时间序列数据进行索引是一种强大的分析技术,可以让我们从数据中发现各种见解。SQL 非常适合这项任务,我已经展示了如何使用窗口函数和不使用窗口函数来构建索引时间序列。接下来,我将展示如何通过使用滚动时间窗口来分析数据,以便在嘈杂的时间序列中找到模式。

滚动时间窗口

时间序列数据通常噪声较大,这是我们寻找模式的主要目标之一的挑战。我们已经看到,聚合数据(例如从月度到年度)可以平滑结果并使其更易于解释。另一种平滑数据的技术是 滚动时间窗口,也称为移动计算,它考虑了多个周期。移动平均值可能是最常见的,但是通过 SQL 的强大功能,任何聚合函数都可以用于分析。滚动时间窗口在各种分析领域广泛应用,包括股票市场、宏观经济趋势和受众测量。一些计算如此常见,以至于它们有自己的首字母缩写:过去十二个月(LTM)、滚动十二个月(TTM)和年初至今(YTD)。

图 3-12 显示了一个滚动时间窗口和累计计算的示例,相对于时间序列中的十月份。

图 3-12. LTM 和 YTD 销售总和的示例

任何滚动时间序列计算的几个重要部分。首先是窗口的大小,即计算中包含的时间周期数。较大的窗口具有更大的平滑效果,但有可能失去对数据重要短期变化的敏感性。较短的窗口具有较少的平滑效果,因此对短期变化更为敏感,但可能无法有效减少噪声。

时间序列计算的第二部分是所使用的聚合函数。正如前面提到的,移动平均值可能是最常见的。还可以使用 SQL 计算移动总和、计数、最小值和最大值。移动计数在用户群体指标中非常有用(请参见以下侧边栏)。移动最小值和最大值有助于理解数据的极端值,这对于计划分析非常有用。

时间序列计算的第三部分是选择包含在窗口中的数据的分区或分组。分析可能需要每年重置窗口。或者分析可能需要每个组件或用户组的不同移动系列。第四章 将更详细地讨论用户组的队列分析,我们将考虑如何随着时间推移,留存和支出等累计值在不同人群之间的差异。分区将通过分组以及窗口函数的 PARTITION BY 语句来控制。

记住这三个部分后,我们将进入移动时间周期的 SQL 代码和计算,继续使用美国零售销售数据集作为示例。

计算滚动时间窗口

现在我们知道滚动时间窗口是什么,它们如何有用以及它们的关键组成部分,让我们开始使用美国零售销售数据集计算它们。我们将从较简单的情况开始,即数据集包含应在窗口中的每个期间的记录,然后在下一节中我们将看看当情况不是这样时该怎么做。

有两种主要方法来计算滚动时间窗口:自连接,可以在任何数据库中使用;窗口函数,在某些数据库中不可用,正如我们所见的那样。在这两种情况下,我们需要相同的结果:一个日期和与窗口大小相对应的数据点数量,我们将对其应用平均值或其他聚合函数。

为了本示例,我们将使用一个 12 个月的窗口来获取滚动年度销售额,因为数据以月为单位。然后,我们将应用平均值来获取 12 个月的移动平均零售销售额。首先,让我们开发出对计算所需的直觉。在此查询中,表的别名a是我们的“锚”表,从中收集日期。首先,我们将查看单个月份,2019 年 12 月。从别名b,查询收集将用于移动平均的 12 个单独月份的销售额。这是通过JOIN子句b.sales_month between a.sales_month - interval '11 months' and a.sales_month完成的,它创建了一个有意的笛卡尔JOIN

SELECT a.sales_month
,a.sales
,b.sales_month as rolling_sales_month
,b.sales as rolling_sales
FROM retail_sales a
JOIN retail_sales b on a.kind_of_business = b.kind_of_business 
 and b.sales_month between a.sales_month - interval '11 months' 
 and a.sales_month
 and b.kind_of_business = 'Women''s clothing stores'
WHERE a.kind_of_business = 'Women''s clothing stores'
and a.sales_month = '2019-12-01'
;

sales_month  sales  rolling_sales_month  rolling_sales
-----------  -----  -------------------  -------------
2019-12-01   4496   2019-01-01           2511
2019-12-01   4496   2019-02-01           2680
2019-12-01   4496   2019-03-01           3585
2019-12-01   4496   2019-04-01           3604
2019-12-01   4496   2019-05-01           3807
2019-12-01   4496   2019-06-01           3272
2019-12-01   4496   2019-07-01           3261
2019-12-01   4496   2019-08-01           3325
2019-12-01   4496   2019-09-01           3080
2019-12-01   4496   2019-10-01           3390
2019-12-01   4496   2019-11-01           3850
2019-12-01   4496   2019-12-01           4496

注意,别名为asales_monthsales数据在窗口的每个月份行中都会重复出现。

警告

请记住,BETWEEN子句中的日期是包含的(结果集中将返回两者)。在前述查询中使用 12 而不是 11 是一个常见错误。当有疑问时,请像我在这里所做的那样检查中间查询结果,以确保期望的时间段数量包含在窗口计算中。

下一步是应用聚合函数——在这种情况下是avg,因为我们想要一个滚动平均值。返回自别名b的记录数量包括在内,以确认每行平均包含 12 个数据点,这是一个有用的数据质量检查。别名a还对sales_month进行了过滤。由于这个数据集始于 1992 年,该年的月份,除了 12 月外,都有少于 12 个历史记录:

SELECT a.sales_month
,a.sales
,avg(b.sales) as moving_avg
,count(b.sales) as records_count
FROM retail_sales a
JOIN retail_sales b on a.kind_of_business = b.kind_of_business 
 and b.sales_month between a.sales_month - interval '11 months' 
 and a.sales_month
 and b.kind_of_business = 'Women''s clothing stores'
WHERE a.kind_of_business = 'Women''s clothing stores'
and a.sales_month >= '1993-01-01'
GROUP BY 1,2
;

sales_month  sales  moving_avg  records_count
-----------  -----  ----------  -------------
1993-01-01   2123   2672.08     12
1993-02-01   2005   2673.25     12
1993-03-01   2442   2676.50     12
...          ...    ...         ...

结果在图 3-13 中显示。虽然每月趋势有噪音,但平滑的移动平均趋势使得识别变化更加容易,比如从 2003 年到 2007 年的增长以及随后到 2011 年的下降。请注意,2020 年初的极端下降在销售后来年反弹之后仍然将移动平均值拉低。

图 3-13. 女装店每月销售额和 12 个月移动平均销售额
提示

向每个别名添加过滤器kind_of_business = 'Women''s clothing stores'并非绝对必要。由于查询使用INNER JOIN,在一张表上进行过滤将自动过滤另一张表。然而,在两张表上同时过滤通常会使查询速度更快,特别是当表很大时。

窗口函数是计算滚动时间窗口的另一种方式。要创建滚动窗口,我们需要使用窗口计算的另一个可选部分:frame clauseframe clause允许您指定要包含在窗口中的记录。默认情况下,包括分区中的所有记录,在许多情况下这都很好用。然而,在更细粒度地控制包含的记录对于像移动窗口计算这样的情况很有用。语法简单,但初次遇到时可能会有些困惑。frame clause可以指定为:

{ RANGE | ROWS | GROUPS } BETWEEN frame_start AND frame_end

大括号中有三种框架类型选项:range、rows 和 groups。这些是您可以指定要包含在结果中的记录的方式,相对于当前行。记录始终从当前分区中选择,并按指定的ORDER BY排序。默认排序为升序(ASC),但可以更改为降序(DESC)。Rows是最简单的,允许您指定应返回的确切行数。Range包括在与当前行相对值的某些边界内的记录。Groups可用于当数据集包含每个销售月份的多行时,例如每个客户一行。

frame_startframe_end可以是以下任何一种:

UNBOUNDED PRECEDING
offset PRECEDING
CURRENT ROW
offset FOLLOWING
UNBOUNDED FOLLOWING

Preceding表示包括当前行之前的行,根据ORDER BY排序。Current row就是当前行,Following表示包括当前行之后的行,根据ORDER BY排序。UNBOUNDED关键字表示在当前行之前或之后包括分区中的所有记录。Offset是记录的数量,通常只是一个整数常数,虽然也可以使用返回整数的字段或表达式。frame clause还具有一个可选的frame_exclusion选项,在此处讨论范围之外。图 3-14 展示了每个窗口帧选项将选取的行的示例。

图 3-14. 窗口帧子句及其包含的行

从分区到排序再到窗口帧,窗口函数具有多种选项来控制计算,使其非常强大且适合用相对简单的语法处理复杂的计算。回到我们的零售销售示例,使用自身JOIN计算的移动平均数可以在少量代码中通过窗口函数实现:

SELECT sales_month
,avg(sales) over (order by sales_month 
                  rows between 11 preceding and current row
                  ) as moving_avg
,count(sales) over (order by sales_month 
                  rows between 11 preceding and current row
                  ) as records_count
FROM retail_sales
WHERE kind_of_business = 'Women''s clothing stores'
;

sales_month  moving_avg  records_count
-----------  ----------  -------------
1992-01-01   1873.00     1
1992-02-01   1932.00     2
1992-03-01   2089.00     3
...          ...         ...
1993-01-01   2672.08     12
1993-02-01   2673.25     12
1993-03-01   2676.50     12
...          ...         ...

在此查询中,窗口按月份(升序)排序,以确保窗口记录按时间顺序排列。帧子句为rows between 11 preceding and current row,因为我知道每个月都有一条记录,我希望包括前 11 个月和当前行的月份在平均值和计数计算中。查询返回所有月份,包括那些没有 11 个先前月份的月份,我们可能希望通过将此查询置于子查询中并在外部查询中按月份或记录数进行过滤来过滤这些记录。

提示

尽管在许多商业背景下从先前时间段计算移动平均值很常见,SQL 窗口函数足够灵活,可以包括未来时间段。它们也可以用于任何数据具有某种排序的场景,不仅限于时间序列分析。

计算滚动平均值或其他移动聚合可以通过自身连接或窗口函数来实现,当数据集中存在每个时间段内的记录时。根据数据库类型和数据集大小,这两种方法可能会有性能差异。不幸的是,很难预测哪一种方法会更有效,或者给出关于使用哪种方法的一般建议。值得尝试两种方法,并注意返回查询结果所需的时间;然后选择运行更快的方法作为默认选择。现在我们已经看到了如何计算滚动时间窗口,接下来我将展示如何在稀疏数据集中计算滚动窗口。

稀疏数据的滚动时间窗口

现实世界中的数据集可能不包含在窗口内的每个时间段的记录。感兴趣的测量可能是季节性的或自然间歇性的。例如,顾客可能会不规则地返回网站购买商品,或者特定产品可能会断货。这导致数据稀疏。

在最后一节中,我展示了如何使用自身连接JOIN子句中的日期间隔来计算滚动窗口。您可能认为这将捕获在 12 个月时间窗口内的任何记录,无论数据集中是否全部包含,您是正确的。但是,当本身不存在该月(或者日或年)的记录时,这种方法存在问题。例如,假设我想计算截至 2019 年 12 月每款鞋店存货的滚动 12 个月销售额。然而,一些鞋子在 12 月之前已经断货,因此在那个月没有销售记录。使用自身连接或窗口函数将返回一个滚动销售数据集,但数据将缺少已断货的鞋子。幸运的是,我们有解决这个问题的方法:使用日期维度。

日期维度,一个静态表,包含每个日历日期的一行,在第二章中被引入。通过这样一个表,我们可以确保查询返回所有感兴趣的日期的结果,无论底层数据集中是否有该日期的数据点。由于retail_sales数据确实包含了所有月份的行,我通过添加一个子查询来模拟稀疏数据集,仅将表过滤为仅包含一月和七月(1 和 7)的sales_month。让我们看看在JOINdate_dim之前的结果,但在聚合之前,以便在应用计算之前开发关于数据的直觉:

SELECT a.date, b.sales_month, b.sales
FROM date_dim a
JOIN 
(
    SELECT sales_month, sales
    FROM retail_sales 
    WHERE kind_of_business = 'Women''s clothing stores'
     and date_part('month',sales_month) in (1,7)
) b on b.sales_month between a.date - interval '11 months' and a.date
WHERE a.date = a.first_day_of_month
 and a.date between '1993-01-01' and '2020-12-01'
;

date        sales_month  sales
----------  -----------  -----
1993-01-01  1992-07-01   2373
1993-01-01  1993-01-01   2123
1993-02-01  1992-07-01   2373
1993-02-01  1993-01-01   2123
1993-03-01  1992-07-01   2373
...         ...          ...

请注意,查询返回了二月和三月的date结果,除了一月,尽管在子查询结果中这些月份没有销售。这是因为日期维度包含了所有月份的记录。过滤器a.date = a.first_day_of_month将结果集限制为每个月一个值,而不是每个月 28 到 31 行的连接结果。该查询的构建与上一节的自连接查询非常相似,其JOIN子句on b.sales_month between a.date - interval '11 months' and a.date与自连接中的JOIN子句形式相同。既然我们已经理解了查询将返回什么结果,我们可以继续应用avg聚合来获取移动平均值:

SELECT a.date
,avg(b.sales) as moving_avg
,count(b.sales) as records
FROM date_dim a
JOIN 
(
    SELECT sales_month, sales
    FROM retail_sales 
    WHERE kind_of_business = 'Women''s clothing stores'
     and date_part('month',sales_month) in (1,7)
) b on b.sales_month between a.date - interval '11 months' and a.date
WHERE a.date = a.first_day_of_month
 and a.date between '1993-01-01' and '2020-12-01'
GROUP BY 1
;

date        moving_avg  records
----------  ----------  -------
1993-01-01  2248.00     2
1993-02-01  2248.00     2
1993-03-01  2248.00     2
...         ...         ...

正如我们之前所看到的,结果集包括每个月的一行;然而,移动平均值在添加新的数据点(在本例中为一月或七月)之前保持不变。每个移动平均值由两个基础数据点组成。在实际用例中,基础数据点的数量可能会有所变化。使用数据维度时,可以使用带有 CASE 语句的聚合来返回当前月份的值,例如:

,max(case when a.date = b.sales_month then b.sales end) 
 as sales_in_month

CASE 语句内的条件可以更改为通过使用相等性、不等式或日期数学偏移返回分析所需的任何基础记录。如果您的数据库中没有日期维度,则可以使用另一种技术来模拟它。在子查询中,SELECT所需的DISTINCT日期,并以与前面示例中相同的方式JOIN它们到您的表中:

SELECT a.sales_month, avg(b.sales) as moving_avg
FROM
(
    SELECT distinct sales_month
    FROM retail_sales
    WHERE sales_month between '1993-01-01' and '2020-12-01'
) a
JOIN retail_sales b on b.sales_month between 
 a.sales_month - interval '11 months' and a.sales_month
 and b.kind_of_business = 'Women''s clothing stores' 
GROUP BY 1
;

sales_month  moving_avg
-----------  ----------
1993-01-01   2672.08
1993-02-01   2673.25
1993-03-01   2676.50
...          ...

在这个示例中,我使用了相同的基础表,因为我知道它包含所有的月份。然而,在实践中,任何包含所需日期的数据库表都可以使用,无论它是否与您要计算滚动聚合的表相关。

使用受控应用笛卡尔JOIN可以在 SQL 中计算包含稀疏或缺失数据的滚动时间窗口。接下来,我们将看看如何计算在分析中经常使用的累积值。

计算累积值

滚动窗口计算,例如移动平均,通常使用固定大小的窗口,如 12 个月,正如我们在上一节中看到的。另一种常用的计算类型是累积值,例如 YTD(年初至今),QTD(季初至今)和 MTD(月初至今)。与固定长度窗口不同,这些类型依赖于一个共同的起始点,并且随着每一行窗口大小逐渐增长。

计算累积值的最简单方法是使用窗口函数。在这个例子中,使用sum来查找每个月的总销售额 YTD。其他分析可能需要月度平均 YTD 或月度最大 YTD,可以通过将sum替换为avgmax来实现。窗口根据PARTITION BY子句重置,本例中为销售月份的年份。ORDER BY子句通常包括时间序列分析中的日期字段。即使您认为数据已经按日期排序,省略ORDER BY可能会导致不正确的结果,因为底层表中数据的排序方式,因此最好包括它:

SELECT sales_month, sales
,sum(sales) over (partition by date_part('year',sales_month) 
                  order by sales_month
                  ) as sales_ytd
FROM retail_sales
WHERE kind_of_business = 'Women''s clothing stores'
;

sales_month  sales  sales_ytd
-----------  -----  ---------
1992-01-01   1873   1873
1992-02-01   1991   3864
1992-03-01   2403   6267
...          ...    ...
1992-12-01   4416   31815
1993-01-01   2123   2123
1993-02-01   2005   4128
...          ...    ...

查询为每个sales_month返回一条记录,该月份的sales以及累积总计sales_ytd。系列从 1992 年开始,然后在每年的 1 月份重置,就像数据集中的每一年一样。2016 年到 2020 年的结果在图 3-15 中显示。前四年显示了类似的年度模式,但 2020 年显然与众不同。

图 3-15. 女装店每月销售和年度累积销售

可以通过使用一个自连接来实现相同的结果,而不是使用窗口函数,该自连接利用笛卡尔连接。在这个例子中,两个表别名根据sales_month的年份连接,以确保聚合值是同一年的,并且每年重新设置。连接子句还指定结果应包括别名b中小于或等于别名a中的sales_month。在 1992 年 1 月,只有别名b中的 1992 年 1 月行符合此条件;在 1992 年 2 月,别名b中的 1992 年 1 月和 2 月行都符合此条件;依此类推:

SELECT a.sales_month, a.sales
,sum(b.sales) as sales_ytd
FROM retail_sales a
JOIN retail_sales b on 
 date_part('year',a.sales_month) = date_part('year',b.sales_month)
 and b.sales_month <= a.sales_month
 and b.kind_of_business = 'Women''s clothing stores'
WHERE a.kind_of_business = 'Women''s clothing stores'
GROUP BY 1,2
;

sales_month  sales  sales_ytd
-----------  -----  ---------
1992-01-01   1873   1873
1992-02-01   1991   3864
1992-03-01   2403   6267
...          ...    ...
1992-12-01   4416   31815
1993-01-01   2123   2123
1993-02-01   2005   4128
...          ...    ...

窗口函数需要较少的代码字符,并且一旦熟悉了语法,通常更容易跟踪它们正在计算的内容。在 SQL 中,解决问题的方法通常不止一种,滚动时间窗口就是一个很好的例子。我发现知道多种方法很有用,因为偶尔会遇到一个棘手的问题,实际上最好用看起来在其他上下文中效率较低的方法来解决。现在我们已经介绍了滚动时间窗口,我们将继续讨论 SQL 中时间序列分析的最后一个主题:季节性。

使用季节性进行分析

季节性 是指在规则间隔内重复的任何模式。与数据中的其他噪音不同,季节性可以预测。"季节性"这个词让人想到一年四季——春、夏、秋、冬——某些数据集包含这些模式。购物模式随季节而变化,从人们购买的衣服和食物到休闲和旅行的支出。冬季假日购物季对许多零售商来说可能是成败的关键。季节性也可能存在于其他时间尺度,从年到分钟。美国的总统选举每四年举行,导致媒体报道出现明显的模式。星期日的周期性是常见的,因为工作和学校主导了星期一到星期五,而家务和休闲活动主导了周末。一天中的时间也是餐厅经历季节性的一种类型,午餐和晚餐时间有高峰期,中间销售较慢。

要了解时间序列中是否存在季节性以及其规模,有助于绘制图表并视觉检查模式。尝试在不同级别进行聚合,从小时到日常、每周和每月。您还应该融入关于数据集的知识。根据您对实体或过程的了解,是否可以猜测出模式?如果可以的话,可以咨询相关主题专家。

让我们来看看零售销售数据集中的一些季节性模式,显示在图 3-16 中。珠宝店有一个高度季节性的模式,每年 12 月的高峰与节日赠礼相关联。书店每年有两个高峰:一个高峰在 8 月,与美国返校季节相对应;另一个高峰从 12 月开始,持续到 1 月,包括假日赠礼期和春季学期的返校时间。第三个例子是杂货店,其月度季节性远低于其他两个时间序列(尽管它们可能在每周的某些日子和一天的某些时段存在季节性)。这并不奇怪:人们全年需要食物。杂货店的销售在 12 月稍有增加,因为节日购物,而在 2 月份则会下降,因为这个月天数较少。

图 3-16. 书店、杂货店和珠宝店销售中的季节性模式示例

季节性可以采取许多形式,但不论如何,有一些常见的分析方法可以解决。处理季节性的一种方法是平滑处理,可以通过将数据聚合到较少粒度的时间段或使用滚动窗口来实现,正如我们之前看到的那样。处理季节性数据的另一种方法是与类似的时间段进行基准比较并分析差异。接下来我将展示几种实现这一目标的方法。

逐期比较:年度同比和月度同比

期间对期间的比较可以采用多种形式。第一种是将一个时间段与系列中的前一个值进行比较,这种做法在分析中非常常见,对于最常用的比较方式甚至有缩写。根据比较的聚合级别,比较可以是年度对比 (YoY)、月度对比 (MoM)、日度对比 (DoD) 等等。

对于这些计算,我们将使用 lag 函数,另一个窗口函数。lag 函数返回一个系列中的前一个或滞后值。lag 函数的形式如下:

lag(*return_value* [,*offset* [,*default*]])

*return_value* 是数据集中的任何字段,因此可以是任何数据类型。可选的 OFFSET 表示从分区中往回多少行来取 *return_value*。默认值为 1,但可以使用任何整数值。还可以选择指定一个 *default* 值,以在没有滞后记录可用时使用。与其他窗口函数一样,lag 函数也是在分区内计算的,排序由 ORDER BY 子句确定。如果没有指定 PARTITION BY 子句,lag 将在整个数据集上往回查找,同样地,如果没有指定 ORDER BY 子句,则使用数据库的顺序。通常建议在 lag 窗口函数中至少包含一个 ORDER BY 子句以控制输出。

提示

lead 窗口函数与 lag 函数的工作方式相同,不同之处在于它返回由偏移确定的后续值。在时间序列中将 ORDER BY 从升序 (ASC) 更改为降序 (DESC),将使 lag 语句效果等同于 lead 语句。或者,可以使用负整数作为 OFFSET 值来返回后续行的值。

让我们将这一方法应用于我们的零售销售数据集,以计算环比增长和同比增长。在本节中,我们将专注于书店销售,因为我是一个真正的书店迷。首先,我们将通过返回滞后的月份和滞后的销售值来开发对 lag 函数返回值的直觉:

SELECT kind_of_business, sales_month, sales
,lag(sales_month) over (partition by kind_of_business 
                        order by sales_month
                        ) as prev_month
,lag(sales) over (partition by kind_of_business 
                  order by sales_month
                  ) as prev_month_sales
FROM retail_sales
WHERE kind_of_business = 'Book stores'
;

kind_of_business  sales_month  sales  prev_month  prev_month_sales
----------------  -----------  -----  ----------  ----------------
Book stores       1992-01-01   790    (null)      (null)
Book stores       1992-02-01   539    1992-01-01  790
Book stores       1992-03-01   535    1992-02-01  539
...               ...          ...    ...         ...

对于每一行,返回上一个 sales_month,以及该月份的 sales,我们可以通过检查结果集的前几行来确认这一点。由于数据集中没有更早的记录,第一行的 prev_monthprev_month_sales 为 null。了解 lag 函数返回的值后,我们可以计算与上一个值的百分比变化:

SELECT kind_of_business, sales_month, sales
,(sales / lag(sales) over (partition by kind_of_business 
                           order by sales_month)
 - 1) * 100 as pct_growth_from_previous
FROM retail_sales
WHERE kind_of_business = 'Book stores'
;

kind_of_business  sales_month  sales  pct_growth_from_previous
----------------  -----------  -----  ------------------------
Book stores       1992-01-01   790    (null)
Book stores       1992-02-01   539    -31.77
Book stores       1992-03-01   535    -0.74
...               ...          ...    ...

从一月到二月销售下降了 31.8%,部分原因是假期后季节性下降以及春季学期开学。从二月到三月销售仅下降了 0.7%。

YoY 比较的计算方式类似,但首先我们需要将销售额聚合到年度水平。因为我们只关注一个 kind_of_business,所以我将该字段从其余的示例中删除,以简化代码。

SELECT sales_year, yearly_sales
,lag(yearly_sales) over (order by sales_year) as prev_year_sales
,(yearly_sales / lag(yearly_sales) over (order by sales_year)
 -1) * 100 as pct_growth_from_previous
FROM
(
    SELECT date_part('year',sales_month) as sales_year
    ,sum(sales) as yearly_sales
    FROM retail_sales
    WHERE kind_of_business = 'Book stores'
    GROUP BY 1
) a
;

sales_year  yearly_sales  prev_year_sales  pct_growth_from_previous
----------  ------------  ---------------  ------------------------
1992.0      8327          (null)           (null)
1993.0      9108          8327             9.37
1994.0      10107         9108             10.96
...         ...           ...              ...

从 1992 年到 1993 年,销售额增长了超过 9.3%,从 1993 年到 1994 年增长了近 11%。这些时期对时期的计算非常有用,但它们不能完全分析数据集中的季节性。例如,在图 3-17 中绘制了 MoM 百分比增长值,它们与原始时间序列一样包含同样多的季节性。

图 3-17. 美国零售书店销售额上个月百分比增长

接下来的部分将演示如何使用 SQL 比较当前值与去年同月的值。

时期对时期比较:同月对比去年

将一个时间段的数据与一个类似的先前时间段的数据进行比较,可以有效地控制季节性变化。先前的时间段可以是前一周的同一天,前一年的同一月,或者数据集合理的其他变化。

为了实现这一比较,我们可以使用lag函数以及一些巧妙的分区方法:我们想要将当前值与其比较的时间单位。在这种情况下,我们将每月的sales与去年同月的sales进行比较。例如,1 月的sales将与前一年的 1 月sales进行比较,2 月的sales将与前一年的 2 月sales进行比较,依此类推。

首先,回想一下date_part函数在使用“month”参数时返回一个数值:

SELECT sales_month
,date_part('month',sales_month)
FROM retail_sales
WHERE kind_of_business = 'Book stores'
;

sales_month  date_part
-----------  ---------
1992-01-01   1.0
1992-02-01   2.0
1992-03-01   3.0
...          ...

接下来,我们将date_part包含在PARTITION BY子句中,以便窗口函数从前一年查找匹配的月份数值。

这是一个窗口函数子句如何在数据库字段之外进行计算的示例,从而使它们具有更高的灵活性。我发现检查中间结果以建立对最终查询返回内容的直觉非常有用,因此我们首先确认带有partition by date_part('mon⁠th',​sales_month)lag函数是否返回预期的值:

SELECT sales_month, sales
,lag(sales_month) over (partition by date_part('month',sales_month) 
                        order by sales_month
                        ) as prev_year_month
,lag(sales) over (partition by date_part('month',sales_month) 
                  order by sales_month
                  ) as prev_year_sales
FROM retail_sales
WHERE kind_of_business = 'Book stores'
;

sales_month  sales  prev_year_month  prev_year_sales
-----------  -----  ---------------  ---------------
1992-01-01   790    (null)           (null)
1993-01-01   998    1992-01-01       790
1994-01-01   1053   1993-01-01       998
...          ...    ...              ...
1992-02-01   539    (null)           (null)
1993-02-01   568    1992-02-01       539
1994-02-01   635    1993-02-01       568
...          ...     ...             ...

第一个lag函数返回前一年的同月数据,我们可以通过查看prev_year_month值来验证。对于 1993-01-01 的sales_monthprev_year_month返回了 1992-01-01,与预期一致,并且prev_year_sales的值为 790,与我们在 1992-01-01 行看到的sales匹配。请注意,由于数据集中没有先前的记录,1992 年的prev_year_monthprev_year_sales为空。

现在我们对lag函数返回正确值很有信心,我们可以计算比较指标,如绝对差异和与上年同期的百分比变化:

SELECT sales_month, sales
,sales - lag(sales) over (partition by date_part('month',sales_month) 
                          order by sales_month
                          ) as absolute_diff
,(sales / lag(sales) over (partition by date_part('month',sales_month) 
                          order by sales_month)
 - 1) * 100 as pct_diff
FROM retail_sales
WHERE kind_of_business = 'Book stores'
;

sales_month  sales  absolute_diff  pct_diff
-----------  -----  -------------  --------
1992-01-01   790    (null)         (null)
1993-01-01   998    208            26.32
1994-01-01   1053   55             5.51
...          ...    ...            ...

现在我们可以在图 3-18 中绘制结果,并更容易地看到增长异常高的月份,如 2002 年 1 月,或者异常低的月份,如 2001 年 12 月。

图 3-18. 书店销售额,年同比销售额绝对差异及年同比增长率

另一个有用的分析工具是创建一个图表,将相同时间段(在本例中是月份)与每个时间序列(在本例中是年份)对齐。为此,我们将创建一个结果集,其中每个月份都有一行,以及我们想考虑的每年的列。为了获取月份,我们可以使用date_partto_char函数,具体取决于我们是想要数字还是文本值的月份。然后,我们将使用聚合函数对数据进行透视。

这个示例使用了max聚合函数,但根据分析情况,可能需要使用sumcount或其他聚合函数。本例中我们将重点放在 1992 年至 1994 年之间:

SELECT date_part('month',sales_month) as month_number
,to_char(sales_month,'Month') as month_name
,max(case when date_part('year',sales_month) = 1992 then sales end) 
 as sales_1992
,max(case when date_part('year',sales_month) = 1993 then sales end) 
 as sales_1993
,max(case when date_part('year',sales_month) = 1994 then sales end) 
 as sales_1994
FROM retail_sales
WHERE kind_of_business = 'Book stores'
 and sales_month between '1992-01-01' and '1994-12-01'
GROUP BY 1,2
;

month_number  month_name  sales_1992  sales_1993  sales_1994
------------  ----------  ----------  ----------  ----------
1.0           January     790         998         1053
2.0           February    539         568         635
3.0           March       535         602         634
4.0           April       523         583         610
5.0           May         552         612         684
6.0           June        589         618         724
7.0           July        592         607         678
8.0           August      894         983         1154
9.0           September   861         903         1022
10.0          October     645         669         732
11.0          November    642         692         772
12.0          December    1165        1273        1409

通过这种方式对齐数据,我们可以立即看到一些趋势。12 月的销售额是全年最高的。1994 年的每个月销售额均高于 1992 年和 1993 年的销售额。8 月至 9 月的销售增长在 1994 年特别明显。

如同图 3-19 中的数据图表所示,趋势更加容易识别。销售额在每个月份年度增长,尽管某些月份的增长幅度较大。有了这些数据和图表,我们可以开始构建有关书店销售的故事,这可能有助于库存规划、营销促销的安排,或者作为美国零售销售更广泛故事中的证据之一。

使用 SQL 可以采用多种技术来消除季节性噪声,以比较时间序列数据。在本节中,我们已经看到了如何使用lag函数来比较当前值与先前可比期间的值,以及如何使用date_partto_char和聚合函数来透视数据。接下来,我将展示一些比较多个先前期间的技术,以进一步控制时间序列数据中的噪声。

图 3-19. 1992 年至 1994 年书店销售额,按月对齐

与多个先前时期比较

将数据与先前可比期间进行比较是减少由季节性引起的噪声的一种有效方式。有时,仅与单个先前期间比较是不够的,特别是如果该先前期间受到异常事件影响。比如,如果一个周一是假期,那么与前一个周一进行比较就很困难。由于经济事件、恶劣天气或站点故障改变了典型行为,前一年的同月份可能是异常的。将当前值与多个先前期间的聚合值进行比较可以帮助平滑这些波动。这些技术也结合了我们学到的使用 SQL 计算滚动时间段和可比先前期间结果的内容。

第一种技术使用了 lag 函数,就像上一节中一样,但在这里我们将利用可选的偏移值。回想一下,当 lag 函数没有提供偏移值时,根据 PARTITION BYORDER BY 子句,函数返回即时前值。偏移值为 2 跳过即时前值,并返回再前一个值;偏移值为 3 返回 3 行前的值,以此类推。

对于本例,我们将比较当前月销售与前三年同月销售情况。和往常一样,首先我们将检查返回的值,确认 SQL 是否按预期工作:

SELECT sales_month, sales
,lag(sales,1) over (partition by date_part('month',sales_month) 
                    order by sales_month
                    ) as prev_sales_1
,lag(sales,2) over (partition by date_part('month',sales_month) 
                    order by sales_month
                    ) as prev_sales_2
,lag(sales,3) over (partition by date_part('month',sales_month) 
                    order by sales_month
                    ) as prev_sales_3
FROM retail_sales
WHERE kind_of_business = 'Book stores'
;

sales_month  sales  prev_sales_1  prev_sales_2  prev_sales_3
-----------  -----  ------------  ------------  ------------
1992-01-01   790    (null)        (null)        (null)
1993-01-01   998    790           (null)        (null)
1994-01-01   1053   998           790           (null)
1995-01-01   1308   1053          998           790
1996-01-01   1373   1308          1053          998
...          ...    ...           ...           ...

在没有先前记录的情况下返回 Null,并且我们可以确认正确的上年同月值已出现。从这里我们可以计算分析需要的任何比较指标,比如本例中的前三个时期滚动平均值的百分比:

SELECT sales_month, sales
,sales / ((prev_sales_1 + prev_sales_2 + prev_sales_3) / 3) 
 as pct_of_3_prev
FROM
(
    SELECT sales_month, sales
    ,lag(sales,1) over (partition by date_part('month',sales_month) 
                        order by sales_month
                        ) as prev_sales_1
    ,lag(sales,2) over (partition by date_part('month',sales_month) 
                        order by sales_month
                        ) as prev_sales_2
    ,lag(sales,3) over (partition by date_part('month',sales_month) 
                        order by sales_month
                        ) as prev_sales_3
    FROM retail_sales
    WHERE kind_of_business = 'Book stores'
) a
;

sales_month  sales  pct_of_3_prev
-----------  -----  -------------
1995-01-01   1308   138.12
1996-01-01   1373   122.69
1997-01-01   1558   125.24
...          ...    ...   
2017-01-01   1386   94.67
2018-01-01   1217   84.98
2019-01-01   1004   74.75
...          ...    ...

我们可以从结果看出,书籍销售量从上世纪 90 年代中期的前三年滚动平均值中增长,但在 2010 年代末期情况有所不同,每年的销售量占该三年滚动平均值的比例在逐年减少。

您可能已经注意到,这个问题与我们在计算滚动时间窗口时看到的问题类似。作为最后一个示例的替代方案,我们可以使用具有框架子句的 avg 窗口函数。为了实现这一点,PARTITION BY 将使用相同的 date_part 函数,ORDER BY 也是相同的。框架子句被添加以包括 rows between 3 preceding and 1 preceding。这包括前 1、2 和 3 行的值,但不包括当前行的值:

SELECT sales_month, sales
,sales / avg(sales) over (partition by date_part('month',sales_month)
                          order by sales_month
                          rows between 3 preceding and 1 preceding
                          ) as pct_of_prev_3
FROM retail_sales
WHERE kind_of_business = 'Book stores'
;

sales_month  sales  pct_of_prev_3
-----------  -----  -------------
1995-01-01   1308   138.12
1996-01-01   1373   122.62
1997-01-01   1558   125.17
...          ...    ... 
2017-01-01   1386   94.62
2018-01-01   1217   84.94
2019-01-01   1004   74.73
...          ...    ...

结果与先前示例的结果相匹配,证实了替代代码的等效性。

提示

如果您仔细观察,您会注意到在使用三个 lag 窗口和使用 avg 窗口函数时,结果中的小数位值略有不同。这是由于数据库在中间计算中处理小数舍入的方式不同。对于许多分析而言,这种差异并不重要,但是如果您处理财务或其他受严格监管的数据时,请注意仔细检查。

分析具有季节性的数据通常涉及试图减少噪声,以便对数据中的基本趋势做出明确的结论。将数据点与多个先前时间段进行比较,可以为我们提供更平滑的趋势,并确定当前时间段实际发生的情况。这要求数据包含足够的历史记录以进行这些比较,但是当我们拥有足够长的时间序列时,这可能会提供深刻的见解。

结论

时间序列分析是分析数据集的一种强大方式。我们已经了解了如何通过日期和时间操作设置我们的数据进行分析。我们讨论了日期维度,并看到如何将其应用于计算滚动时间窗口。我们研究了期间对比的计算方法以及如何分析具有季节性模式的数据。在下一章中,我们将深入探讨一个与时间序列分析相关的主题:队列分析。

¹ 出版商由于对数据质量的担忧,压制了 2020 年 10 月和 11 月的数据点。由于 2020 年大流行期间商店关闭,数据的收集可能变得更加困难。

第四章:Cohort 分析

在第三章中,我们讨论了时间序列分析。现在,我们将转向另一种相关的分析类型,它在许多商业和其他应用中都有用途:cohort 分析。

我记得我第一次接触 cohort 分析的时候。那时我在一家小型创业公司做数据分析师的工作。我正在审查一份我与 CEO 共同完成的购买分析,他建议我按 cohort 来拆分客户群,看看行为是否随时间而变化。我以为那只是一些花哨的商学院东西,可能没什么用,但他是 CEO,所以我当然顺着他。结果证明这不只是一个噱头。将人群按 cohort 分组并随时间跟踪它们是分析数据和避免各种偏见的强大方式。cohorts 可以提供关于亚群体如何不同以及随时间如何变化的线索。

在这一章中,我们首先来看看什么是 cohorts 以及某些类型的 cohort 分析的基本组成部分。在介绍用于示例的立法者数据集之后,我们将学习如何构建留存分析,并处理诸如定义 cohort 和处理稀疏数据等各种挑战。接下来,我们将涵盖生存分析、回归分析和累积计算,这些都类似于留存分析中 SQL 代码的结构。最后,我们将看看如何将 cohort 分析与横截面分析结合起来,以理解随时间变化的人群构成。

Cohorts:一个有用的分析框架

在我们进入代码之前,我会定义什么是 cohorts,考虑我们可以用这种分析回答哪些类型的问题,并描述任何 cohort 分析的组成部分。

一个cohort是一群在我们开始观察时具有某些特征的个体,下文将描述。Cohort 成员通常是人,但也可以是我们想研究的任何类型的实体:公司、产品或物理世界现象。cohort 中的个体可能意识到他们的成员身份,就像一个一年级班级的孩子意识到他们是第一年级同学群体的一部分,或者药物试验的参与者意识到他们是接受治疗的组的一部分。在其他时候,实体被虚拟地分成 cohort,例如软件公司将所有在某一年获得的客户分组,以研究他们保持客户的时间长短。无论何时,都要考虑将实体分组而不通知它们的伦理影响,如果要对它们施加任何不同的对待。

队列分析是一种有益的比较实体群体随时间变化的方式。许多重要行为需要几周、几个月或几年的时间来发生或发展,队列分析是理解这些变化的一种方法。队列分析提供了一个框架,用于检测队列特征与这些长期趋势之间的相关性,这可以导致关于因果驱动因素的假设。例如,通过营销活动获得的客户可能具有与那些被朋友说服尝试公司产品的客户不同的长期购买模式。队列分析可用于监测新用户或客户的新队列,并评估它们与先前队列的比较情况。这种监测可以提供早期警报信号,表明新客户的情况出现了问题(或者好转)。队列分析还用于挖掘历史数据。A/B 测试,在第七章中讨论,是确定因果关系的黄金标准,但我们不能回到过去,并为我们感兴趣的过去每个问题运行每个测试。当然,我们应该谨慎地将因果意义附加到队列分析中,而是将队列分析用作了解客户并产生未来可以严格测试的假设的一种方式。

队列分析有三个组成部分:队列分组、观察队列的时间序列数据和衡量队列成员执行的聚合指标。

队列分组通常基于起始日期:客户的第一次购买或订阅日期,学生入学日期等。然而,队列也可以根据其他特征形成,这些特征可以是固有的或随时间变化的。固有特征包括出生年份和国家/地区,或公司成立年份。随时间变化的特征包括居住城市和婚姻状况。当使用这些特征时,我们需要确保只在起始日期上对队列进行分组,否则实体可能会在队列组之间跳跃。

队列还是段?

这两个术语通常以类似的方式使用,甚至可以互换,但出于清晰起见,区分它们是值得的。队列是一组具有共同起始日期并随时间跟踪的用户(或其他实体)。是在某一时间点具有共同特征或一组特征的用户分组,而不考虑它们的起始日期。与队列类似,段可以基于固有因素(如年龄)或行为特征进行分组。在同一月份注册的用户可以被放入一个队列并随时间跟踪。或者可以使用队列分析探索不同的用户分组,以便了解哪些分组具有最有价值的特征。本章将涵盖的分析,如保留,可以帮助在市场细分背后提供具体数据。

任何队列分析的第二个组成部分是时间序列。这是一系列购买、登录、互动或其他由客户或被队列化的实体执行的行动。重要的是时间序列覆盖实体的整个生命周期,否则早期队列中会有生存偏差。生存偏差发生在只有留下的客户在数据集中;已流失的客户因不再存在而被排除,因此其余客户看起来比新队列的质量更高或更合适(见“生存偏差”)。同样重要的是,时间序列足够长,以使实体完成感兴趣的行动。例如,如果客户倾向于每月购买一次,那么需要几个月的时间序列。另一方面,如果购买只发生一次一年,那么几年的时间序列将更可取。不可避免地,最近获取的客户没有足够的时间完成动作,与过去获取的客户相比。为了进行规范化,队列分析通常测量从起始日期起经过的周期数,而不是日历月份。通过这种方式,可以比较周期 1、周期 2 等不同的队列,看看它们随时间的演变,而不考虑动作实际发生的月份。这些间隔可以是天、周、月或年。

总量度量 应与对组织健康至关重要的行动有关,比如继续使用或购买产品的客户。通常在队列中对度量值进行汇总,使用 sumcountaverage,尽管任何相关的汇总都可以。其结果是一个时间序列,随后可用于理解随时间行为的变化。

在本章中,我将涵盖四种类型的队列分析:留存、生存、重购行为或再购买行为,以及累积行为。

留存

留存关注的是队列成员在特定日期的时间序列中是否有记录,表示从起始日期起的周期数。这在任何预期有重复行为的组织中都很有用,无论是玩在线游戏、使用产品还是续订订阅,它有助于回答产品有多粘性或吸引力以及未来可能出现多少实体的问题。

生存

生存关注的是在数据集中保留了多少实体一定长度的时间或更长时间,而不论到那时为止的行动次数或频率。生存对于回答预期有多少人口能够保持下去是很有用的——无论是因为没有流失或死亡,还是因为没有毕业或完成某些要求而保持下去。

重购行为

重购或重复购买行为关注的是在固定时间窗口内是否发生了动作超过某个最小阈值——通常仅仅是超过一次。这种分析类型在行为是间歇性和不可预测的情况下非常有用,比如在零售业中,它描述了每个队列中重复购买者的份额在固定时间窗口内。

累积的

累积计算关注的是在一个或多个固定时间窗口内测得的总数量或总金额,而不管它们在该窗口内何时发生。累积计算经常用于计算客户生命周期价值(LTV 或 CLTV)。

四种类型的队列分析允许我们比较子群体,并了解它们随时间的差异,以便做出更好的产品、营销和财务决策。不同类型的计算方法相似,因此我们将从保留开始介绍,并展示如何修改保留代码以计算其他类型。在我们深入构建队列分析之前,让我们先看看我们将在本章的示例中使用的数据集。

立法者数据集

本章中的 SQL 示例将使用一个维护在Github 库中的过去和现任美国国会成员的数据集。在美国,国会负责制定法律或立法,所以其成员也被称为立法者。由于数据集是一个 JSON 文件,我已经应用了一些转换来为分析产生更适合的数据模型,并且我已经发布了适合在书的 GitHub legislators 文件夹中跟随示例的数据格式。

源代码库有一个很好的数据字典,所以我不会在这里重复所有细节。然而,我会提供一些细节,这些细节应该帮助那些不熟悉美国政府的人跟随本章的分析。

国会有两个议院,参议院(在数据集中为“sen”)和众议院(“rep”)。每个州有两名参议员,他们任期为六年。代表根据人口分配给各州;每个代表有一个他们独自代表的选区。代表任期为两年。在某位立法者死亡、当选或被任命到更高职务的情况下,任何一院的实际任期都可能较短。立法者通过担任领导职务累积权力和影响力,因此经常参加连任。最后,立法者可能属于一个政党,或者他们可能是“独立”的。在现代,绝大多数立法者是民主党人或共和党人,两党之间的竞争是众所周知的。立法者偶尔在任职期间更换政党。

在分析中,我们将使用两个表:legislatorslegislators_termslegislators 表包含数据集中所有包括的人员列表,包括生日、性别和一组可以用于在其他数据集中查找该人员的 ID 字段。legislators_terms 表包含每位立法者任期的记录,包括开始和结束日期,以及其他属性,如议会和党派。id_bioguide 字段用作立法者的唯一标识符,并出现在每个表中。图 4-1 展示了 legislators 数据的示例。图 4-2 展示了 legislators_terms 数据的示例。

sfda_0401.png

图 4-1. legislators 表的示例

sfda_0402.png

图 4-2. legislators_terms 表的示例

现在我们已经了解了什么是队列分析,以及我们将用于示例的数据集,让我们来看看如何编写用于保留分析的 SQL。SQL 将帮助我们回答的关键问题是:一旦代表就职,他们会持续多长时间?

保留

最常见的一种队列分析是保留分析。保留即是保持或继续某事物。许多技能需要练习才能保持。企业通常希望客户继续购买他们的产品或使用他们的服务,因为保留客户比获取新客户更具盈利性。雇主希望保留员工,因为招聘替代者既昂贵又耗时。当选官员寻求连任,以便继续推动选民的重要事务。

保留分析中的主要问题是,起始队列的大小——订阅者或员工数量、支出金额或其他关键指标——是否会随时间保持恒定、衰减或增加。当存在增加或减少时,变化的数量和速度也是有趣的问题。在大多数保留分析中,起始队列大小随时间倾向于衰减,因为一旦形成队列,队列就无法获得新成员,只能失去成员。收入是一个有趣的例外,因为客户队列可以在后续几个月花费比首个月更多的金额,即使其中一些客户已经流失。

留存分析使用数据集中每个周期从起始日期起的实体的count或金钱或操作的sum,并通过将这个数值除以第一个时间段内的实体,金钱或操作的countsum来进行标准化。结果以百分比表示,起始周期的留存率始终为 100%。随着时间的推移,基于计数的留存率通常会下降,并且永远不会超过 100%,而基于金钱或行动的留存率虽然通常会下降,但可以在某一时间段内增加并超过 100%。留存分析的输出通常以表格或图形形式显示,称为留存曲线。在本章后面,我们将看到多个留存曲线的示例。

留存曲线图可以用来比较队列。要注意的第一个特征是曲线在最初几个周期内的形状,通常会有一个初始的陡峭下降。对于许多消费类应用程序,头几个月失去一半的队列是很常见的。具有比其他更陡或更缓的曲线的队列可能表明产品或客户获取来源发生了变化,值得进一步调查。要寻找的第二个特征是曲线在一定周期后是否变平或继续快速下降至零。平坦的曲线表明有一个时间点,此后大多数留存的队列成员将无限期留在其中。如果队列成员在一段时间后返回或重新激活,则留存曲线可能出现上升的情况,有时被称为笑脸曲线。最后,监控订阅收入的留存曲线,以了解时间内每个客户的增加收入迹象,是一个健康的 SaaS 软件业务的标志。

本节将展示如何创建留存分析,从时间序列本身和其他表中添加队列分组,并处理时间序列数据中可能出现的缺失和稀疏数据。有了这个框架,您将在随后的部分中学习如何进行修改,以创建其他相关类型的队列分析。因此,本节关于留存的内容将是本章中最长的部分,因为您将逐步构建代码并发展您对计算的直觉。

SQL 用于基本留存曲线

对于留存分析,与其他队列分析一样,我们需要三个组成部分:队列定义,操作的时间序列,以及衡量与组织或流程相关的某种聚合指标。在我们的情况下,队列成员将是立法者,时间序列将是每位立法者任职的任期,而感兴趣的指标将是从起始日期起每个周期仍在职的人数的count

我们将从计算基本保持率开始,然后再进入包含各种队列分组示例的部分。第一步是找到每位议员上任的第一个日期(first_term)。我们将使用此日期来计算时间序列中每个后续日期的期数。为此,取term_start的最小值,并GROUP BY每个id_bioguide,这是每位议员的唯一标识符:

SELECT id_bioguide
,min(term_start) as first_term
FROM legislators_terms 
GROUP BY 1
;

id_bioguide  first_term
-----------  ----------
A000118      1975-01-14
P000281      1933-03-09
K000039      1933-03-09
...          ...

下一步是将这段代码放入子查询中,并将其JOIN到时间序列中。应用age函数计算每个议员的term_startfirst_term之间的间隔。对结果应用date_part函数,以年为单位转换为年度周期数。由于选举每两年或六年举行一次,我们将使用年作为计算period的时间间隔。我们可以使用更短的间隔,但在这个数据集中,每日或每周的波动很小。对于该周期有记录的议员的count是保持数:

SELECT date_part('year',age(b.term_start,a.first_term)) as period
,count(distinct a.id_bioguide) as cohort_retained
FROM
(
    SELECT id_bioguide, min(term_start) as first_term
    FROM legislators_terms 
    GROUP BY 1
) a
JOIN legislators_terms b on a.id_bioguide = b.id_bioguide 
GROUP BY 1
;

period  cohort_retained
------  ---------------
0.0     12518
1.0     3600
2.0     3619
...     ...
提示

在支持datediff函数的数据库中,可以用这个更简单的函数代替date_partage构造:

datediff('year',first_term,term_start)

有些数据库,比如 Oracle,将date_part放在最后:

datediff(first_term,term_start,'year'

现在我们有了期数和每个期数保留的议员数量,最后一步是计算总cohort_size并填充到每一行,以便可以将cohort_retained除以它。first_value窗口函数根据ORDER BY中设置的顺序返回PARTITION BY子句中的第一个记录,这是获取每行中队列大小的便捷方式。在这种情况下,cohort_size来自整个数据集中的第一个记录,因此PARTITION BY被省略:

first_value(cohort_retained) over (order by period) as cohort_size

要找到保持百分比,将cohort_retained值除以相同的计算结果:

SELECT period
,first_value(cohort_retained) over (order by period) as cohort_size
,cohort_retained
,cohort_retained / 
 first_value(cohort_retained) over (order by period) as pct_retained
FROM
(
    SELECT date_part('year',age(b.term_start,a.first_term)) as period
    ,count(distinct a.id_bioguide) as cohort_retained
    FROM
    (
        SELECT id_bioguide, min(term_start) as first_term
        FROM legislators_terms 
        GROUP BY 1
    ) a
    JOIN legislators_terms b on a.id_bioguide = b.id_bioguide 
    GROUP BY 1
) aa
;

period  cohort_size  cohort_retained  pct_retained
------  -----------  ---------------  ------------
0.0     12518        12518            1.0000
1.0     12518        3600             0.2876
2.0     12518        3619             0.2891
...     ...          ...              ...

现在我们有了一个保持率的计算,我们可以看到在第 0 期保留的议员百分比与一年后开始的第二届记录之间有明显的下降。如图图 4-3 所示,展示了曲线如何变平,并最终趋近于零,因为即使是任期最长的议员最终也会退休或去世。

图 4-3. 美国议员第一届任期开始后的保持率

我们可以将队列保持结果重新整理成表格格式展示。使用带有 CASE 语句的聚合函数进行数据的旋转和展平;在本例中使用了max,但是其他聚合函数如minavg将返回相同的结果。保持率计算为 0 到 4 年,但可以通过相同的模式添加额外的年份:

SELECT cohort_size
,max(case when period = 0 then pct_retained end) as yr0
,max(case when period = 1 then pct_retained end) as yr1
,max(case when period = 2 then pct_retained end) as yr2
,max(case when period = 3 then pct_retained end) as yr3
,max(case when period = 4 then pct_retained end) as yr4
FROM
(
    SELECT period
    ,first_value(cohort_retained) over (order by period) 
     as cohort_size
    ,cohort_retained 
     / first_value(cohort_retained) over (order by period)
     as pct_retained
    FROM
    (
        SELECT 
        date_part('year',age(b.term_start,a.first_term)) as period
        ,count(*) as cohort_retained
        FROM
        (
            SELECT id_bioguide, min(term_start) as first_term
            FROM legislators_terms 
            GROUP BY 1
        ) a
        JOIN legislators_terms b on a.id_bioguide = b.id_bioguide 
        GROUP BY 1
    ) aa
) aaa
GROUP BY 1
;

cohort_size  yr0     yr1     yr2     yr3     yr4
-----------  ------  ------  ------  ------  ------
12518        1.0000  0.2876  0.2891  0.1463  0.2564

保持率似乎相当低,从图表中我们可以看到在最初几年内呈现出锯齿状。造成这种情况的一个原因是代表的任期为两年,而参议员的任期为六年,但数据集只包含新任期的开始记录;因此,我们缺少议员仍在职但未开始新任期的年份的数据。在这种情况下,每年测量保持率是误导性的。一种选择是仅在两年或六年周期内测量保持率,但我们还可以采用另一种策略来填补“缺失”的数据。我将在回到形成队列组之前介绍这一点。

调整时间序列以提高保持准确性

我们在第二章讨论了清理“缺失”数据的技术,我们将在本节中转向这些技术,以便为议员形成更平滑且更真实的保持曲线。在处理时间序列数据,例如队列分析时,重要的是考虑不仅存在的数据,还要考虑这些数据是否准确地反映了每个时间段内实体的存在或缺失。在数据捕获到的事件导致实体在数据中存在一段未被记录的时间时,这尤其成为问题。例如,客户购买软件订阅在交易时被记录在数据中,但客户有权在几个月或几年内使用该软件,而这段时间不一定在数据中得到体现。为了纠正这一点,我们需要一种方法来推导实体仍然存在的时间跨度,无论是通过明确的结束日期还是知道订阅或任期的长度。然后我们可以说,在这些开始和结束日期之间的任何日期该实体都存在。

在议员数据集中,我们有一个记录任期开始日期的记录,但我们缺少这样一个概念,即这个日期“赋予”议员能够在众议院或参议院中任职两年或六年的资格。为了纠正这一点并平滑曲线,我们需要填补议员在新任期之间仍在职的年份的“缺失”数值。由于这个数据集为每个任期都包括一个term_end值,我将展示如何通过填补开始和结束值之间的日期来创建更准确的队列保持分析。然后,我将展示如何在数据集不包含结束日期时填补结束日期。

使用数据中定义的开始日期和结束日期来计算保留是最准确的方法。对于以下示例,如果立法者在年底 12 月 31 日仍在任职,则视为保留。在美国宪法第二十修正案之前,任期始于 3 月 4 日,但之后开始日期移至 1 月 3 日,或者如果第三天是周末,则移至随后的工作日。由于特别的非周期性选举或任命以填补空缺席位,立法者可以在一年中的其他日期宣誓就职。因此,term_start日期在 1 月聚集,但在全年范围内分布。虽然我们可以选择另一个日期,但 12 月 31 日是围绕这些不同开始日期进行规范化的策略之一。

第一步是创建一个数据集,其中包含每位立法者在任的每个 12 月 31 日的记录。这可以通过将找到first_term的子查询与legislators_terms表进行JOIN来实现,以查找每个任期的term_startterm_end。对date_dim的第二JOIN检索从开始到结束日期之间的日期,限制返回值为c.month_name = 'December'和 c.day_of_month = 31。计算perioddate_dim中的datefirst_term之间的年数。请注意,即使在 1 月宣誓就职和 12 月 31 日之间可能已经过去了超过 11 个月,第一年仍然显示为 0:

SELECT a.id_bioguide, a.first_term
,b.term_start, b.term_end
,c.date
,date_part('year',age(c.date,a.first_term)) as period
FROM
(
    SELECT id_bioguide, min(term_start) as first_term
    FROM legislators_terms 
    GROUP BY 1
) a
JOIN legislators_terms b on a.id_bioguide = b.id_bioguide 
LEFT JOIN date_dim c on c.date between b.term_start and b.term_end 
and c.month_name = 'December' and c.day_of_month = 31
;

id_bioguide  first_term  term_start  term_end    date        period
-----------  ----------  ----------  ----------  ----------  ------
B000944      1993-01-05  1993-01-05  1995-01-03  1993-12-31  0.0
B000944      1993-01-05  1993-01-05  1995-01-03  1994-12-31  1.0
C000127      1993-01-05  1993-01-05  1995-01-03  1993-12-31  0.0
...          ...         ...         ...         ...         ...
提示

如果没有日期维度可用,可以通过几种方式创建包含所需日期的子查询。如果您的数据库支持generate_series,可以创建一个子查询来返回所需的日期:

SELECT generate_series::date as date
FROM generate_series('1770-12-31','2020-12-
31',interval '1 year')

你可能想把这个保存为一个表或视图以供以后使用。或者,您可以查询数据集或数据库中具有完整日期集的任何其他表。在这种情况下,表中包含所有必要的年份,但我们将使用make_date函数为每年创建一个 12 月 31 日的日期:

SELECT distinct
make_date(date_part('year',term_start)::int,12,31)
FROM legislators_terms

有许多创造性的方法可以获取所需的日期系列。请使用在查询中可用且最简单的任何方法。

现在我们每个date(年末)都有一行,我们希望计算保留率。下一步是计算每个期间的cohort_retained,使用id_bioguidecount完成。在period上使用coalesce函数设置默认值为 0 时,处理议员在同一年内开始和结束任期的情况,这样便为在该年内服务提供了信用:

SELECT 
coalesce(date_part('year',age(c.date,a.first_term)),0) as period
,count(distinct a.id_bioguide) as cohort_retained
FROM
(
    SELECT id_bioguide, min(term_start) as first_term
    FROM legislators_terms 
    GROUP BY 1
) a
JOIN legislators_terms b on a.id_bioguide = b.id_bioguide 
LEFT JOIN date_dim c on c.date between b.term_start and b.term_end 
and c.month_name = 'December' and c.day_of_month = 31
GROUP BY 1
;

period  cohort_retained
------  ---------------
0.0     12518
1.0     12328
2.0     8166
...     ...

最后一步是像以前一样使用first_value窗口函数计算cohort_sizepct_retained

SELECT period
,first_value(cohort_retained) over (order by period) as cohort_size
,cohort_retained
,cohort_retained * 1.0 / 
 first_value(cohort_retained) over (order by period) as pct_retained
FROM
(
    SELECT coalesce(date_part('year',age(c.date,a.first_term)),0) as period
    ,count(distinct a.id_bioguide) as cohort_retained
    FROM
    (
        SELECT id_bioguide, min(term_start) as first_term
        FROM legislators_terms 
        GROUP BY 1
    ) a
    JOIN legislators_terms b on a.id_bioguide = b.id_bioguide 
    LEFT JOIN date_dim c on c.date between b.term_start and b.term_end 
    and c.month_name = 'December' and c.day_of_month = 31
    GROUP BY 1
) aa
;

period  cohort_size  cohort_retained  pct_retained
------  -----------  ---------------  ------------
0.0     12518        12518            1.0000
1.0     12518        12328            0.9848
2.0     12518        8166             0.6523
...     ...          ...              ...

现在通过图表显示的图 4-4 的结果更加准确。几乎所有立法者在第 1 年仍然在任职,而在第 2 年发生的第一个大幅下降发生在一些代表未能连任时。

图 4-4. 调整后的议员任职年限

如果数据集不包含结束日期,则有几种选项可以补充。一种选项是在已知订阅或任期长度时,将固定的间隔添加到开始日期。这可以通过日期数学通过将恒定间隔添加到term_start来完成。在这里,一个 CASE 语句处理了两种term_type的添加:

SELECT a.id_bioguide, a.first_term
,b.term_start
,case when b.term_type = 'rep' then b.term_start + interval '2 years'
      when b.term_type = 'sen' then b.term_start + interval '6 years'
      end as term_end
FROM
(
    SELECT id_bioguide, min(term_start) as first_term
    FROM legislators_terms 
    GROUP BY 1
) a
JOIN legislators_terms b on a.id_bioguide = b.id_bioguide 
;

id_bioguide  first_term  term_start  term_end
-----------  ----------  ----------  ----------
B000944      1993-01-05  1993-01-05  1995-01-05
C000127      1993-01-05  1993-01-05  1995-01-05
C000141      1987-01-06  1987-01-06  1989-01-06
...          ...         ...         ...

这段代码块可以随后插入保留代码中以推导出periodpct_retained。这种方法的缺点在于它未能捕捉到立法者未能完成全任期的情况,这种情况可能出现在死亡或被提升到更高职位的事件中。

第二个选择是使用后续开始日期减去一天作为term_end日期。这可以通过lead窗口函数计算。这个函数类似于我们之前使用的lag函数,但是它不是返回分区中较早的行的值,而是返回分区中较晚的行的值,如ORDER BY子句所确定。默认是一行,我们将在这里使用这个默认值,但是该函数有一个可选参数指示不同数量的行。在这里,我们使用lead找到后续任期的term_start日期,然后减去间隔'1 day'来推导出term_end

SELECT a.id_bioguide, a.first_term
,b.term_start
,lead(b.term_start) over (partition by a.id_bioguide 
                          order by b.term_start) 
 - interval '1 day' as term_end
FROM
(
    SELECT id_bioguide, min(term_start) as first_term
    FROM legislators_terms 
    GROUP BY 1
) a
JOIN legislators_terms b on a.id_bioguide = b.id_bioguide 
;

id_bioguide  first_term  term_start  term_end
-----------  ----------  ----------  ----------
A000001      1951-01-03  1951-01-03  (null)
A000002      1947-01-03  1947-01-03  1949-01-02
A000002      1947-01-03  1949-01-03  1951-01-02
...          ...         ...         ...

这段代码块随后可以插入保留代码中。这种方法有一些缺点。首先,当没有后续任期时,lead函数返回空值,使得该任期没有term_end。在这种情况下可以使用默认值,如上一个例子中所示的默认间隔。第二个缺点是该方法假设任期总是连续的,没有在职期间的空闲时间。尽管大多数立法者倾向于连续服务,直到他们的国会生涯结束,但确实存在任期之间跨越数年的间隔的例子。

当我们进行填补缺失数据的调整时,必须谨慎对待我们所做的假设。在订阅或期限为基础的情境中,显式的开始和结束日期通常是最准确的。在没有结束日期并且我们合理预期大多数客户或用户将保持所假设的持续时间时,可以使用另外两种方法中的任意一种:添加一个固定的间隔或者相对于下一个开始日期设置结束日期。

现在我们已经看到如何计算基本的保留曲线并纠正缺失日期,我们可以开始添加队列组。比较不同组之间的保留率是进行队列分析的主要原因之一。接下来,我将讨论如何从时间序列本身形成组,并在此之后,我将讨论如何从其他表中的数据形成队列组。

从时间序列本身派生队列

现在我们有了计算保留的 SQL 代码,我们可以开始将实体分割成队列。在本节中,我将展示如何从时间序列本身推导出队列分组。首先,我将讨论基于第一日期的基于时间的队列,并解释如何基于时间序列的其他属性进行队列分组。

建立队列的最常见方式是基于时间序列中实体首次出现的日期或时间。这意味着队列保持分析只需要一个表:时间序列本身。按首次出现或行动进行分组是有趣的,因为通常在不同时间开始的群体表现不同。对于消费者服务,早期采纳者通常更加热情,并且保持方式不同于后来的采纳者;而在 SaaS 软件中,后期采纳者可能更好地保持,因为产品更加成熟。基于时间的队列可以按照组织中有意义的任何时间粒度进行分组,尽管周、月或年队列是常见的。如果不确定使用何种分组方式,请尝试使用不同的分组运行队列分析,但不要将队列大小设定得太小,以便看到有意义的模式出现。幸运的是,一旦了解如何构建队列和保持分析,替换不同的时间粒度就变得简单了。

第一个例子将使用年度队列,然后我将演示如何更换为世纪。我们将考虑的关键问题是,立法者首次就职的时代是否与他们的保留有任何相关性。政治趋势和公众情绪确实会随时间而变化,但变化的幅度是多少呢?

要计算年度队列,我们首先将之前计算的first_term的年份添加到查询中,以找到periodcohort_retained

SELECT date_part('year',a.first_term) as first_year
,coalesce(date_part('year',age(c.date,a.first_term)),0) as period
,count(distinct a.id_bioguide) as cohort_retained
FROM
(
    SELECT id_bioguide, min(term_start) as first_term
    FROM legislators_terms 
    GROUP BY 1
) a
JOIN legislators_terms b on a.id_bioguide = b.id_bioguide 
LEFT JOIN date_dim c on c.date between b.term_start and b.term_end 
and c.month_name = 'December' and c.day_of_month = 31
GROUP BY 1,2
;

first_year  period  cohort_retained
----------  ------  ---------------
1789.0      0.0     89
1789.0      2.0     89
1789.0      3.0     57
...         ...     ...

然后将此查询用作子查询,并像以前一样在外部查询中计算cohort_sizepct_retained。但是,在这种情况下,我们需要一个PARTITION BY子句,其中包括first_year,以便first_value仅在该first_year的行集内计算,而不是在子查询的整个结果集中计算:

SELECT first_year, period
,first_value(cohort_retained) over (partition by first_year 
                                    order by period) as cohort_size
,cohort_retained
,cohort_retained / 
 first_value(cohort_retained) over (partition by first_year 
                                    order by period) as pct_retained
FROM
(
    SELECT date_part('year',a.first_term) as first_year
    ,coalesce(date_part('year',age(c.date,a.first_term)),0) as period
    ,count(distinct a.id_bioguide) as cohort_retained
    FROM
    (
        SELECT id_bioguide, min(term_start) as first_term
        FROM legislators_terms 
        GROUP BY 1
    ) a
    JOIN legislators_terms b on a.id_bioguide = b.id_bioguide 
    LEFT JOIN date_dim c on c.date between b.term_start and b.term_end 
    and c.month_name = 'December' and c.day_of_month = 31
    GROUP BY 1,2
) aa
;

first_year  period  cohort_size  cohort_retained  pct_retained
----------  ------  -----------  ---------------  ------------
1789.0      0.0     89           89               1.0000
1789.0      2.0     89           89               1.0000
1789.0      3.0     89           57               0.6404
...         ...     ...          ...              ...

此数据集包含两百多个起始年份,过多无法轻松绘制或在表中检查。接下来,我们将以较少的粒度间隔查看,并将立法者按其first_term的世纪进行队列分组。这一变更通过在子查询aa中将date_part函数中的year替换为century轻松完成。请记住,世纪名称与它们所代表的年份有所偏移,因此 18 世纪从 1700 年到 1799 年,19 世纪从 1800 年到 1899 年,依此类推。在first_value函数中的分区更改为first_century字段:

SELECT first_century, period
,first_value(cohort_retained) over (partition by first_century 
                                    order by period) as cohort_size
,cohort_retained
,cohort_retained / 
 first_value(cohort_retained) over (partition by first_century 
                                    order by period) as pct_retained
FROM
(
    SELECT date_part('century',a.first_term) as first_century
    ,coalesce(date_part('year',age(c.date,a.first_term)),0) as period
    ,count(distinct a.id_bioguide) as cohort_retained
    FROM
    (
        SELECT id_bioguide, min(term_start) as first_term
        FROM legislators_terms 
        GROUP BY 1
    ) a
    JOIN legislators_terms b on a.id_bioguide = b.id_bioguide 
    LEFT JOIN date_dim c on c.date between b.term_start and b.term_end 
    and c.month_name = 'December' and c.day_of_month = 31
    GROUP BY 1,2
) aa
ORDER BY 1,2
;

first_century  period  cohort_size  cohort_retained  pct_retained
-------------  ------  -----------  ---------------  ------------
18.0           0.0     368          368              1.0000
18.0           1.0     368          360              0.9783
18.0           2.0     368          242              0.6576
...            ...     ...          ...              ...

结果在 图 4-5 中进行了图表化。在早期年代,首次当选于 20 世纪或 21 世纪的人员的保留率较高。21 世纪仍在进行中,因此许多这些立法者还没有机会在任职五年或更长时间,尽管他们仍然包含在分母中。我们可能需要考虑从分析中排除 21 世纪,但我保留它在这里以展示由于这种情况而导致的保留曲线的人为下降。

图 4-5. 按首次任期开始世纪保留的立法者

除了第一个日期外,时间序列中的队列可以根据表格中的值来定义。 legislators_terms 表格具有 state 字段,指示该任期中人员所代表的州。我们可以利用这一点创建队列,并且将它们基于第一个州以确保任何代表多个州的人员仅在数据中出现一次。

警告

在一个随时间变化的属性上进行队列处理时,确保每个实体只分配一个值非常重要。否则,该实体可能会出现在多个队列中,从而引入分析中的偏差。通常使用数据集中最早记录的值来确定队列。

要找到每位立法者的第一个州,我们可以使用 first_value 窗口函数。在这个例子中,我们还将 min 函数转换为窗口函数,以避免冗长的 GROUP BY 子句:

SELECT distinct id_bioguide
,min(term_start) over (partition by id_bioguide) as first_term
,first_value(state) over (partition by id_bioguide 
                          order by term_start) as first_state
FROM legislators_terms 
;

id_bioguide  first_term  first_state
-----------  ----------  -----------
C000001      1893-08-07  GA
R000584      2009-01-06  ID
W000215      1975-01-14  CA
...          ...         ...

然后我们可以将此代码插入我们的保留代码中,以查找按first_state保留的情况:

SELECT first_state, period
,first_value(cohort_retained) over (partition by first_state 
                                    order by period) as cohort_size
,cohort_retained
,cohort_retained / 
 first_value(cohort_retained) over (partition by first_state 
                                    order by period) as pct_retained
FROM
(
    SELECT a.first_state
    ,coalesce(date_part('year',age(c.date,a.first_term)),0) as period
    ,count(distinct a.id_bioguide) as cohort_retained
    FROM
    (
        SELECT distinct id_bioguide
        ,min(term_start) over (partition by id_bioguide) as first_term
        ,first_value(state) over (partition by id_bioguide order by term_start) 
         as first_state
        FROM legislators_terms 
    ) a
    JOIN legislators_terms b on a.id_bioguide = b.id_bioguide 
    LEFT JOIN date_dim c on c.date between b.term_start and b.term_end 
    and c.month_name = 'December' and c.day_of_month = 31
    GROUP BY 1,2
) aa
;

first_state  period  cohort_size  cohort_retained  pct_retained
-----------  ------  -----------  ---------------  ------------
AK           0.0     19           19               1.0000
AK           1.0     19           19               1.0000
AK           2.0     19           15               0.7895
...          ...     ...          ...              ...

对具有最高总立法者数的五个州的保留曲线进行了图表化,参见 图 4-6。伊利诺伊州和马萨诸塞州的当选者保留率最高,而纽约州的保留率最低。确定原因将是这项分析的一个有趣的分支。

图 4-6. 按首个州保留的立法者:按总立法者数前五个州

使用 min 日期为每个实体定义时间序列中的队列相对简单,然后根据分析的适当性将该日期转换为月份、年份或世纪。在粒度之间进行切换也很简单,允许测试多个选项以找到对组织有意义的分组方式。其他属性可以与 first_value 窗口函数一起用于队列化。接下来,我们将转向队列属性来自时间序列之外的表格的情况。

从单独的表格定义队列

经常定义队列的特征存在于与包含时间序列不同的表中。例如,数据库可能具有客户表,其中包含客户可以按获取来源或注册日期进行分组的信息。从其他表或甚至子查询添加属性相对简单,并且可以在保留分析及本章后续讨论的相关分析中完成。

对于本例,我们将考虑立法者的性别是否对其保留率有影响。legislators表具有gender字段,其中 F 表示女性,M 表示男性,我们可以使用它来对立法者进行分组。为此,我们将JOIN legislators表作为别名d,以将gender添加到cohort_retained的计算中,取代年份或世纪:

SELECT d.gender
,coalesce(date_part('year',age(c.date,a.first_term)),0) as period
,count(distinct a.id_bioguide) as cohort_retained
FROM
(
    SELECT id_bioguide, min(term_start) as first_term
    FROM legislators_terms 
    GROUP BY 1
) a
JOIN legislators_terms b on a.id_bioguide = b.id_bioguide 
LEFT JOIN date_dim c on c.date between b.term_start and b.term_end 
and c.month_name = 'December' and c.day_of_month = 31
JOIN legislators d on a.id_bioguide = d.id_bioguide
GROUP BY 1,2
;

gender  period  cohort_retained
------  ------  ---------------
F       0.0     366
M       0.0     12152
F       1.0     349
M       1.0     11979
...     ...     ...

立法任期中服务的男性比女性明显更多。现在我们可以计算percent_retained,以便比较这些群体的保留率:

SELECT gender, period
,first_value(cohort_retained) over (partition by gender 
                                    order by period) as cohort_size
,cohort_retained
,cohort_retained/ 
 first_value(cohort_retained) over (partition by gender 
                                    order by period) as pct_retained
FROM
(
    SELECT d.gender
    ,coalesce(date_part('year',age(c.date,a.first_term)),0) as period
    ,count(distinct a.id_bioguide) as cohort_retained
    FROM
    (
        SELECT id_bioguide, min(term_start) as first_term
        FROM legislators_terms 
        GROUP BY 1
    ) a
    JOIN legislators_terms b on a.id_bioguide = b.id_bioguide 
    LEFT JOIN date_dim c on c.date between b.term_start and b.term_end 
    and c.month_name = 'December' and c.day_of_month = 31
    JOIN legislators d on a.id_bioguide = d.id_bioguide
    GROUP BY 1,2
) aa
;

gender  period  cohort_size  cohort_retained  pct_retained
------  ------  -----------  ---------------  ------------
F       0.0     366          366              1.0000
M       0.0     12152        12152            1.0000
F       1.0     366          349              0.9536
M       1.0     12152        11979            0.9858
...     ...     ...          ...              ...

我们可以从图 4-7 的结果中看出,与其男性对手相比,女性立法者在第 2 到第 29 期间的保留率更高。第一位女性立法者直到 1917 年才上任,当时 Jeannette Rankin 作为来自蒙大拿州的共和党代表加入了众议院。正如我们之前看到的,保留率在更近的世纪有所增加。

图 4-7. 按性别分类的立法者保留率

为了进行更公平的比较,我们可以限制分析中包含的立法者仅限于那些自国会有女性以来开始任职的人。我们可以通过向子查询aa添加WHERE过滤器来实现这一点。在这里,结果也限制为那些在 2000 年之前开始的人,以确保队列至少有 20 年的可能留任时间:

SELECT gender, period
,first_value(cohort_retained) over (partition by gender 
                                    order by period) as cohort_size
,cohort_retained
,cohort_retained / 
 first_value(cohort_retained) over (partition by gender 
                                    order by period) as pct_retained
FROM
(
    SELECT d.gender
    ,coalesce(date_part('year',age(c.date,a.first_term)),0) as period
    ,count(distinct a.id_bioguide) as cohort_retained
    FROM
    (
        SELECT id_bioguide, min(term_start) as first_term
        FROM legislators_terms 
        GROUP BY 1
    ) a
    JOIN legislators_terms b on a.id_bioguide = b.id_bioguide 
    LEFT JOIN date_dim c on c.date between b.term_start and b.term_end 
    and c.month_name = 'December' and c.day_of_month = 31
    JOIN legislators d on a.id_bioguide = d.id_bioguide
    WHERE a.first_term between '1917-01-01' and '1999-12-31'
    GROUP BY 1,2
) aa
;

gender  period  cohort_size  cohort_retained  pct_retained
------  ------  -----------  ---------------  ------------
F       0.0     200          200              1.0000
M       0.0     3833         3833             1.0000
F       1.0     200          187              0.9350
M       1.0     3833         3769             0.9833
...     ...     ...          ...              ...

男性立法者仍然比女性立法者人数多,但差距较小。队列的保留率见图 4-8。在修订后的队列中,男性立法者在第 7 年之前有更高的保留率,但从第 12 年开始,女性立法者的保留率更高。这两种基于性别的队列分析的差异突显了设置适当的队列和确保它们具有相同时间量来参加或完成其他感兴趣行动的重要性。为了进一步改进此分析,我们可以按照起始年或十年以及性别进行队列分析,以控制 20 世纪和 21 世纪初的保留率的额外变化。

图 4-8. 按性别分类的立法者保留率:1917 年至 1999 年的队列

Cohorts 可以通过时间序列和其他表格的方式定义。通过我们开发的框架,可以替换子查询、视图或其他派生表,从而打开一整套计算作为 cohort 基础的可能性。可以使用多个标准,比如起始年份和性别。将人群根据多个标准分为 cohort 时需要注意的是,这可能导致稀疏的 cohort,即某些定义的组在所有时间段的数据集中太小,无法代表所有时间段。接下来的部分将讨论克服这一挑战的方法。

处理稀疏 cohort

在理想的数据集中,每个 cohort 在所有感兴趣的期间都有一些操作或记录。我们已经看到由于订阅或任期跨越多个期间而可能出现“缺失”日期的情况,以及如何使用日期维度来推断中间日期进行校正。另一个问题可能是,由于分组标准,cohort 变得太小,结果数据中只偶尔出现。当 cohort 消失在结果集中时,我们希望它以零的留任值出现而不是空值。这个问题称为 稀疏 cohort,可以通过谨慎使用 LEFT JOIN 来解决。

为了演示这一点,让我们尝试通过首次代表的州来对女性立法者进行 cohort,看看留任情况是否有所不同。我们已经看到女性立法者相对较少。进一步按州对他们进行 cohort 很可能会创建一些稀疏的 cohort,其中成员非常少。在进行代码调整之前,让我们将 first_state(在从时间序列派生 cohort 的部分计算)添加到之前的性别示例中,并查看结果:

SELECT first_state, gender, period
,first_value(cohort_retained) over (partition by first_state, gender 
                                    order by period) as cohort_size
,cohort_retained
,cohort_retained / 
 first_value(cohort_retained) over (partition by first_state, gender 
                                    order by period) as pct_retained
FROM
(
    SELECT a.first_state, d.gender
    ,coalesce(date_part('year',age(c.date,a.first_term)),0) as period
    ,count(distinct a.id_bioguide) as cohort_retained
    FROM
    (
        SELECT distinct id_bioguide
        ,min(term_start) over (partition by id_bioguide) as first_term
        ,first_value(state) over (partition by id_bioguide 
                                  order by term_start) as first_state
        FROM legislators_terms 
    ) a
    JOIN legislators_terms b on a.id_bioguide = b.id_bioguide 
    LEFT JOIN date_dim c on c.date between b.term_start and b.term_end 
    and c.month_name = 'December' and c.day_of_month = 31
    JOIN legislators d on a.id_bioguide = d.id_bioguide
    WHERE a.first_term between '1917-01-01' and '1999-12-31'
    GROUP BY 1,2,3
) aa
;

first_state  gender  period  cohort_size  cohort_retained pct_retained
-----------  ------  ------  -----------  --------------- ------------
AZ           F       0.0     2            2               1.0000
AZ           M       0.0     26           26              1.0000
AZ           F       1.0     2            2               1.0000
...          ...     ...     ...          ...             ...

绘制前 20 个期间的结果,例如 图 4-9,显示了这些稀疏的 cohort。阿拉斯加没有女性立法者,而亚利桑那州的女性留任曲线在第三年后消失。只有加利福尼亚州,一个有很多议员的大州,两性都有完整的留任曲线。其他小州和大州的情况也如此。

图 4-9. 按性别和首个州的立法者留任

现在让我们看看如何确保每个期间都有记录,以便查询在留任的情况下返回零值而不是空值。第一步是查询所有 period 和 cohort 属性的组合,例如 first_stategender,每个组合的起始 cohort_size。这可以通过将计算 cohort 的子查询 aa 与生成一个从 0 到 20 的整数的子查询进行 JOIN,条件为 on 1 = 1 来完成。这是强制进行笛卡尔 JOIN 的便捷方式,当两个子查询没有共同字段时非常有用。

SELECT aa.gender, aa.first_state, cc.period, aa.cohort_size
FROM
(
    SELECT b.gender, a.first_state 
    ,count(distinct a.id_bioguide) as cohort_size
    FROM 
    (
        SELECT distinct id_bioguide
        ,min(term_start) over (partition by id_bioguide) as first_term
        ,first_value(state) over (partition by id_bioguide 
                                  order by term_start) as first_state
        FROM legislators_terms 
    ) a
    JOIN legislators b on a.id_bioguide = b.id_bioguide
    WHERE a.first_term between '1917-01-01' and '1999-12-31' 
    GROUP BY 1,2
) aa
JOIN
(
    SELECT generate_series as period 
    FROM generate_series(0,20,1)
) cc on 1 = 1
;

gender  state  period  cohort
------  -----  ------  ------
F       AL     0       3
F       AL     1       3
F       AL     2       3
...    ...     ...     ...

下一步是将此与实际任期中的时间段进行JOIN,并使用LEFT JOIN确保所有时间段都保留在最终结果中:

SELECT aaa.gender, aaa.first_state, aaa.period, aaa.cohort_size
,coalesce(ddd.cohort_retained,0) as cohort_retained
,coalesce(ddd.cohort_retained,0) / aaa.cohort_size as pct_retained
FROM
(
    SELECT aa.gender, aa.first_state, cc.period, aa.cohort_size
    FROM
    (
        SELECT b.gender, a.first_state
        ,count(distinct a.id_bioguide) as cohort_size
        FROM 
        (
            SELECT distinct id_bioguide
            ,min(term_start) over (partition by id_bioguide) 
             as first_term
            ,first_value(state) over (partition by id_bioguide 
                                      order by term_start) 
                                      as first_state
            FROM legislators_terms 
        ) a
        JOIN legislators b on a.id_bioguide = b.id_bioguide 
        WHERE a.first_term between '1917-01-01' and '1999-12-31' 
        GROUP BY 1,2
    ) aa
    JOIN
    (
        SELECT generate_series as period 
        FROM generate_series(0,20,1)
    ) cc on 1 = 1
) aaa
LEFT JOIN
(
    SELECT d.first_state, g.gender
    ,coalesce(date_part('year',age(f.date,d.first_term)),0) as period
    ,count(distinct d.id_bioguide) as cohort_retained
    FROM
    (
        SELECT distinct id_bioguide
        ,min(term_start) over (partition by id_bioguide) as first_term
        ,first_value(state) over (partition by id_bioguide 
                                  order by term_start) as first_state
        FROM legislators_terms 
    ) d
    JOIN legislators_terms e on d.id_bioguide = e.id_bioguide 
    LEFT JOIN date_dim f on f.date between e.term_start and e.term_end 
     and f.month_name = 'December' and f.day_of_month = 31
    JOIN legislators g on d.id_bioguide = g.id_bioguide
    WHERE d.first_term between '1917-01-01' and '1999-12-31'
    GROUP BY 1,2,3
) ddd on aaa.gender = ddd.gender and aaa.first_state = ddd.first_state 
and aaa.period = ddd.period
;

gender  first_state  period  cohort_size  cohort_retained pct_retained
------  -----------  ------  -----------  --------------- ------------
F       AL           0       3            3               1.0000
F       AL           1       3            1               0.3333
F       AL           2       3            0               0.0000
...    ...           ...     ...          ...             ...

然后我们可以旋转结果,并确认每个周期中每个人群都存在一个值:

gender  first_state  yr0    yr2     yr4     yr6     yr8      yr10
------  -----------  -----  ------  ------  ------  ------  ------
F       AL           1.000  0.0000  0.0000  0.0000  0.0000  0.0000
F       AR           1.000  0.8000  0.2000  0.4000  0.4000  0.4000
F       CA           1.000  0.9200  0.8000  0.6400  0.6800  0.6800
...     ...          ...    ...     ...     ...     ...     ...

请注意,此时 SQL 代码已变得非常长。编写用于人群留存分析的 SQL 的较难部分之一是保持所有逻辑清晰和代码组织良好,这是我将在第八章中进一步讨论的主题。在构建留存代码时,逐步进行检查结果并逐步进行,对个别人群进行抽样检查,以验证最终结果的准确性,我发现这非常有帮助。

人群可以用多种方式定义。到目前为止,我们已将所有人群标准化为时间序列数据中首次出现的日期。然而,这并不是唯一的选择,从实体寿命中段开始进行有趣的分析也是可以的。在结束我们对留存分析的工作之前,让我们看看这另一种定义人群的方式。

从非首次日期定义人群

通常,基于时间的人群是从实体在时间序列中首次出现或从某些其他最早日期(如注册日期)开始定义的。然而,基于不同日期的人群分析也可能非常有用和深刻。例如,我们可能想要查看在特定日期之前使用服务的所有客户的留存情况。这种类型的分析可以用来了解产品或营销变化是否对现有客户产生了长期影响。

当使用非首次日期时,我们需要确切地定义每个人群包含的标准。一种选择是选择在特定日历日期出现的实体。这在 SQL 代码中相对直接,但如果大部分常规用户不是每天都出现,会导致留存率取决于选择的确切日期,这可能会出现问题。一个纠正这个问题的选择是计算几个起始日期的留存率,然后平均结果。

另一个选择是使用一段时间窗口,如一周或一个月。在该窗口内出现在数据集中的任何实体都包含在人群中。虽然这种方法通常更能代表业务或过程,但折衷之处在于 SQL 代码将变得更加复杂,而查询时间可能会变慢,因为数据库计算更为密集。在查询性能和结果准确性之间找到合适的平衡是一门艺术。

让我们看看如何通过考虑在 2000 年任职的立法者数据集来计算此类中期分析。我们将按term_type对其进行队列化,“sen”表示参议员,“rep”表示代表。定义将包括在 2000 年任何时间内任职的任何立法者:在 2000 年之前开始并在 2000 年期间或之后结束其任期的人符合条件,以及在 2000 年开始任期的人。我们可以硬编码 2000 年的任何日期作为first_term,因为稍后我们将检查他们是否在 2000 年任职。也计算了此窗口内任期的min_start,以便在后续步骤中使用:

SELECT distinct id_bioguide, term_type, date('2000-01-01') as first_term
,min(term_start) as min_start
FROM legislators_terms 
WHERE term_start <= '2000-12-31' and term_end >= '2000-01-01'
GROUP BY 1,2,3
;

id_bioguide  term_type  first_term  min_start
-----------  ---------  ----------  ---------
C000858      sen        2000-01-01  1997-01-07
G000333      sen        2000-01-01  1995-01-04
M000350      rep        2000-01-01  1999-01-06
...          ...        ...         ...

我们可以将此插入我们的保持代码中,并进行两个调整。首先,在子查询alegislators_terms表之间添加了一个额外的JOIN条件,以便仅返回从min_start日期开始或之后开始的任期。其次,在date_dim中添加了一个额外的过滤器,以便仅返回 2000 年或之后的日期:

SELECT term_type, period
,first_value(cohort_retained) over (partition by term_type order by period) 
 as cohort_size
,cohort_retained
,cohort_retained / 
 first_value(cohort_retained) over (partition by term_type order by period) 
 as pct_retained
FROM
(
    SELECT a.term_type
    ,coalesce(date_part('year',age(c.date,a.first_term)),0) as period
    ,count(distinct a.id_bioguide) as cohort_retained
    FROM
    (
        SELECT distinct id_bioguide, term_type
        ,date('2000-01-01') as first_term
        ,min(term_start) as min_start
        FROM legislators_terms 
        WHERE term_start <= '2000-12-31' and term_end >= '2000-01-01'
        GROUP BY 1,2,3
    ) a
    JOIN legislators_terms b on a.id_bioguide = b.id_bioguide 
    and b.term_start >= a.min_start
    LEFT JOIN date_dim c on c.date between b.term_start and b.term_end 
    and c.month_name = 'December' and c.day_of_month = 31 
    and c.year >= 2000
    GROUP BY 1,2
) aa
;

term_type  period  cohort_size  cohort_retained  pct_retained
---------  ------  -----------  ---------------  ------------
rep        0.0     440          440              1.0000
sen        0.0     101          101              1.0000
rep        1.0     440          392              0.8909
sen        1.0     101          89               0.8812
...        ...     ...          ...              ...

图 4-10 显示,尽管参议员任期较长,两个队列的保持率相似,在超过 10 年后,参议员的保持率实际上更低。进一步分析比较它们首次当选的不同年份或其他队列属性可能会得出一些有趣的见解。

图 4-10. 2000 年在任立法者的任期类型保持率

除了起始值之外的值上的队列常见用例是当试图分析实体达到阈值后的保持,例如一定数量的购买或一定金额的花费。与任何队列一样,定义什么条件使实体成为队列成员以及将用作起始日期的日期非常重要。

队列保持是了解时间序列数据集中实体行为的强大方法。我们已经看到了如何使用 SQL 计算保持以及如何基于时间序列本身或其他表进行队列化,以及在实体生命周期中的中间点。我们还研究了如何使用函数和JOIN来调整时间序列中的日期,并补偿稀疏队列。与队列保持相关的分析有几种类型:分析、存活率、回报率和累积计算,这些都是我们为保持开发的 SQL 代码的基础。让我们接下来看看它们。

相关队列分析

在上一节中,我们学习了如何为队列保留分析编写 SQL。保留分析捕捉了一个实体是否在特定日期或时间窗口内的时间序列数据集中。除了在特定日期的存在之外,分析通常还关心实体持续存在的时间长短,以及实体是否执行了多个动作以及这些动作的数量。可以使用类似于保留的代码来回答所有这些问题,并且非常适合于您喜欢的任何队列标准。让我们先看看这些中的第一个,生存率。

生存率

生存率,也称为生存分析,涉及到持续存在多长时间的问题,或者直到特定事件(如流失或死亡)发生的时间长度。生存分析可以回答关于群体中有多少比例可能会在某个时间点之后继续存在的问题。队列可以帮助识别或至少提供关于哪些特征或情况会增加或减少生存可能性的假设。

这类似于保留分析,但不是计算实体是否在特定时期存在,而是计算实体是否在该时期或后来的时间序列中存在。然后计算总队列的份额。通常根据分析的数据集性质选择一个或多个期间。例如,如果我们想知道在一周或更长时间内存活的游戏玩家的份额,我们可以检查从开始后一周发生的动作,并考虑那些仍然存活的玩家。另一方面,如果我们关心在一定年限后仍在校的学生人数,我们可以在数据集中寻找毕业事件的缺失。可以通过计算平均寿命或典型寿命来SELECT一个或多个期间,或者选择对组织或分析过程有意义的时间期间,如一个月、一年或更长时间期间。

在本例中,我们将看看在其第一任期开始后存活了十年或更长时间的立法者的份额。由于我们不需要知道每个任期的具体日期,我们可以先计算第一个和最后一个term_start日期,使用minmax聚合:

SELECT id_bioguide
,min(term_start) as first_term
,max(term_start) as last_term
FROM legislators_terms
GROUP BY 1
;

id_bioguide  first_term  last_term
-----------  ----------  ---------
A000118      1975-01-14  1977-01-04
P000281      1933-03-09  1937-01-05
K000039      1933-03-09  1951-01-03
...          ...         ...

接下来,我们在查询中添加一个date_part函数来找到min term_start的世纪,并使用age函数计算tenure,作为minmax term_start之间年数的数量:

SELECT id_bioguide
,date_part('century',min(term_start)) as first_century
,min(term_start) as first_term
,max(term_start) as last_term
,date_part('year',age(max(term_start),min(term_start))) as tenure
FROM legislators_terms
GROUP BY 1
;

id_bioguide  first_century  first_term  last_term  tenure
-----------  -------------  ----------  ---------  ------
A000118      20.0           1975-01-14  1977-01-04  1.0
P000281      20.0           1933-03-09  1937-01-05  3.0
K000039      20.0           1933-03-09  1951-01-03  17.0
...          ...            ...         ...         ...

最后,我们使用count计算所有立法者的cohort_size,并使用 CASE 语句和count聚合计算至少存活 10 年的人数。通过将这两个值相除,可以找到存活的百分比:

SELECT first_century
,count(distinct id_bioguide) as cohort_size
,count(distinct case when tenure >= 10 then id_bioguide 
                     end) as survived_10
,count(distinct case when tenure >= 10 then id_bioguide end) 
 / count(distinct id_bioguide) as pct_survived_10
FROM
(
    SELECT id_bioguide
    ,date_part('century',min(term_start)) as first_century
    ,min(term_start) as first_term
    ,max(term_start) as last_term
    ,date_part('year',age(max(term_start),min(term_start))) as tenure
    FROM legislators_terms
    GROUP BY 1
) a
GROUP BY 1
;

century  cohort  survived_10  pct_survived_10
-------  ------  -----------  ---------------
18       368     83           0.2255
19       6299    892          0.1416
20       5091    1853         0.3640
21       760     119          0.1566

由于术语可能连续也可能不连续,我们还可以计算每个世纪在议员中生存了五个或更多任期的比例。在子查询中,添加一个count以找到每位议员的总任期数。然后在外部查询中,将有五个或更多任期的议员数除以总队列大小:

SELECT first_century
,count(distinct id_bioguide) as cohort_size
,count(distinct case when total_terms >= 5 then id_bioguide end) 
 as survived_5
,count(distinct case when total_terms >= 5 then id_bioguide end)
 / count(distinct id_bioguide) as pct_survived_5_terms
FROM
(
    SELECT id_bioguide
    ,date_part('century',min(term_start)) as first_century
    ,count(term_start) as total_terms
    FROM legislators_terms
    GROUP BY 1
) a
GROUP BY 1
;

century  cohort  survived_5  pct_survived_5_terms
-------  ------  ----------  --------------------
18       368     63          0.1712
19       6299    711         0.1129
20       5091    2153        0.4229
21       760     205         0.2697

十年或五个任期有些随意。我们还可以计算每个年份或期间的存活率,并以图表或表格形式显示结果。在这里,我们计算了从 1 到 20 个任期的存活率。这通过与包含由generate_series函数生成的整数的子查询的笛卡尔JOIN来实现:

SELECT a.first_century, b.terms
,count(distinct id_bioguide) as cohort
,count(distinct case when a.total_terms >= b.terms then id_bioguide 
                     end) as cohort_survived
,count(distinct case when a.total_terms >= b.terms then id_bioguide 
                     end)
 / count(distinct id_bioguide) as pct_survived
FROM
(
    SELECT id_bioguide
    ,date_part('century',min(term_start)) as first_century
    ,count(term_start) as total_terms
    FROM legislators_terms
    GROUP BY 1
) a
JOIN
(
    SELECT generate_series as terms 
    FROM generate_series(1,20,1)
) b on 1 = 1
GROUP BY 1,2
;

century  terms  cohort  cohort_survived  pct_survived
-------  -----  ------  ---------------  ------------
18       1      368     368              1.0000
18       2      368     249              0.6766
18       3      368     153              0.4157
...      ...    ...     ...              ...

结果在图 4-11 中绘制出来。存活率在 20 世纪最高,这与我们先前看到的保留率在 20 世纪也最高的结果一致。

图 4-11。议员的存活率:在任职了那么多个任期或更长时间的群体的份额。

存活率与保留率密切相关。而保留率计算的是在从开始计算的特定期间内存在的实体数,存活率只考虑实体是否在特定期间或更晚时作为存在。因此,代码更简单,因为它只需要时间序列中的第一个和最后一个日期,或日期的计数。与保留率的分组类似于保留率的分组,群体定义可以来自时间序列内部,也可以来自另一个表或子查询中。

接下来我们将考虑另一种在某些方面与存活率相反的分析类型。与其计算实体在数据集中是否在某一特定时间或更晚存在,我们将计算实体是否在某一特定时间段或更早时返回或重复某个动作。这被称为返回率或重复购买行为。

Returnship,或者重复购买行为。

存活率对于理解一个群体可能会留下多长时间很有用。另一种有用的群体分析类型旨在了解一个群体成员在给定时间窗口内是否可以预期返回以及在该窗口期间的活动强度。这被称为返回率重复购买行为

例如,一个电子商务网站可能想要知道通过营销活动获得了多少新买家,以及这些买家是否成为了重复购买者。解决这个问题的一种方法是简单地计算每位客户的总购买次数。然而,比较两年前获得的客户和一个月前获得的客户并不公平,因为前者有更长的时间来回购。老一批人几乎肯定比新一批看起来更有价值。虽然在某种意义上这是正确的,但这并没有完全展示出各个群体在整个生命周期内的行为方式。

为了在不同起始日期的队列之间进行公平比较,我们需要基于时间窗口或从第一个日期开始的固定时间窗口创建分析,并考虑队列成员是否在该窗口内返回。这样,只要我们仅包括那些完整窗口已过去的队列,每个队列就有相同的考虑时间。在零售组织中,回归分析很常见,但也可以应用在其他领域。例如,大学可能想要了解有多少学生在第二门课程中注册,医院可能对初次事件后需要进行后续医疗治疗的患者感兴趣。

为了展示回归分析,我们可以对立法者数据集提出一个新问题:有多少立法者拥有多个任期类型,具体而言,有多少人从代表开始,最终成为参议员(有些参议员后来成为代表,但这种情况较少见)。由于这种转变的人数相对较少,我们将立法者按其首次成为代表的世纪进行分类。

第一步是找出每个世纪的队列规模,仅使用term_type = 'rep'的子查询和之前看到的date_part计算:

SELECT date_part('century',a.first_term) as cohort_century
,count(id_bioguide) as reps
FROM
(
    SELECT id_bioguide, min(term_start) as first_term
    FROM legislators_terms
    WHERE term_type = 'rep'
    GROUP BY 1
) a
GROUP BY 1
;

cohort_century  reps
--------------  ----
18              299
19              5773
20              4481
21              683

接下来,我们将执行类似的计算,通过与legislators_terms表的JOIN来查找后来成为参议员的代表。这通过b.term_type = 'sen'b.term_start > a.first_term子句来实现:

SELECT date_part('century',a.first_term) as cohort_century
,count(distinct a.id_bioguide) as rep_and_sen
FROM
(
    SELECT id_bioguide, min(term_start) as first_term
    FROM legislators_terms
    WHERE term_type = 'rep'
    GROUP BY 1
) a
JOIN legislators_terms b on a.id_bioguide = b.id_bioguide
and b.term_type = 'sen' and b.term_start > a.first_term
GROUP BY 1
;

cohort_century  rep_and_sen
--------------  -----------
18              57
19              329
20              254
21              25

最后,我们JOIN这两个子查询并计算成为参议员的代表的百分比。使用LEFT JOIN通常建议使用这个子句,以确保所有队列都被包含,无论是否发生了随后的事件。如果某个世纪没有代表成为参议员,我们仍然希望将该世纪包含在结果集中:

SELECT aa.cohort_century
,bb.rep_and_sen / aa.reps as pct_rep_and_sen
FROM
(
    SELECT date_part('century',a.first_term) as cohort_century
    ,count(id_bioguide) as reps
    FROM
    (
        SELECT id_bioguide, min(term_start) as first_term
        FROM legislators_terms
        WHERE term_type = 'rep'
        GROUP BY 1
    ) a
    GROUP BY 1
) aa
LEFT JOIN
(
    SELECT date_part('century',b.first_term) as cohort_century
    ,count(distinct b.id_bioguide) as rep_and_sen
    FROM
    (
        SELECT id_bioguide, min(term_start) as first_term
        FROM legislators_terms
        WHERE term_type = 'rep'
        GROUP BY 1
    ) b
    JOIN legislators_terms c on b.id_bioguide = b.id_bioguide
    and c.term_type = 'sen' and c.term_start > b.first_term
    GROUP BY 1
) bb on aa.cohort_century = bb.cohort_century
;

cohort_century  pct_rep_and_sen
--------------  ---------------
18              0.1906
19              0.0570
20              0.0567
21              0.0366

18 世纪的代表最有可能成为参议员。然而,我们尚未应用时间窗口来确保公平比较。尽管我们可以安全地假设所有在 18 世纪和 19 世纪服务过的立法者现已不再生存,但许多首次当选于 20 世纪和 21 世纪的人仍处于事业的中期。将过滤器WHERE age(c.term_start, b.first_term) <= interval '10 years'添加到子查询bb中可以创建一个 10 年的时间窗口。请注意,通过更改间隔中的常数,可以轻松地调整窗口的大小。附加到子查询a的额外过滤器WHERE first_term <= '2009-12-31'排除了在数据集汇编时事业不足 10 年的人:

SELECT aa.cohort_century
,bb.rep_and_sen * 100.0 / aa.reps as pct_10_yrs
FROM
(
    SELECT date_part('century',a.first_term)::int as cohort_century
    ,count(id_bioguide) as reps
    FROM
    (
        SELECT id_bioguide, min(term_start) as first_term
        FROM legislators_terms
        WHERE term_type = 'rep'
        GROUP BY 1
    ) a
    WHERE first_term <= '2009-12-31'
    GROUP BY 1
) aa
LEFT JOIN
(
    SELECT date_part('century',b.first_term)::int as cohort_century
    ,count(distinct b.id_bioguide) as rep_and_sen
    FROM
    (
        SELECT id_bioguide, min(term_start) as first_term
        FROM legislators_terms
        WHERE term_type = 'rep'
        GROUP BY 1
    ) b
    JOIN legislators_terms c on b.id_bioguide = c.id_bioguide
    and c.term_type = 'sen' and c.term_start > b.first_term
    WHERE age(c.term_start, b.first_term) <= interval '10 years'
    GROUP BY 1
) bb on aa.cohort_century = bb.cohort_century
;

Cohort_century  pct_10_yrs
--------------  ----------
18              0.0970
19              0.0244
20              0.0348
21              0.0764

在这种新调整后,18 世纪仍然拥有最高比例的代表在 10 年内成为参议员,但 21 世纪的比例排名第二,20 世纪的比例高于 19 世纪。

由于 10 年有些随意,我们可能还想比较几个时间窗口。一种选择是多次运行查询,并注意结果。另一种选择是通过在count distinct聚合内使用一组 CASE 语句来形成间隔,而不是在WHERE子句中指定间隔:

SELECT aa.cohort_century
,bb.rep_and_sen_5_yrs * 1.0 / aa.reps as pct_5_yrs
,bb.rep_and_sen_10_yrs * 1.0 / aa.reps as pct_10_yrs
,bb.rep_and_sen_15_yrs * 1.0 / aa.reps as pct_15_yrs
FROM
(
    SELECT date_part('century',a.first_term) as cohort_century
    ,count(id_bioguide) as reps
    FROM
    (
        SELECT id_bioguide, min(term_start) as first_term
        FROM legislators_terms
        WHERE term_type = 'rep'
        GROUP BY 1
    ) a
    WHERE first_term <= '2009-12-31'
    GROUP BY 1
) aa
LEFT JOIN
(
    SELECT date_part('century',b.first_term) as cohort_century
    ,count(distinct case when age(c.term_start,b.first_term) 
                              <= interval '5 years' 
                         then b.id_bioguide end) as rep_and_sen_5_yrs
    ,count(distinct case when age(c.term_start,b.first_term) 
                              <= interval '10 years' 
                         then b.id_bioguide end) as rep_and_sen_10_yrs
    ,count(distinct case when age(c.term_start,b.first_term) 
                              <= interval '15 years' 
                         then b.id_bioguide end) as rep_and_sen_15_yrs
    FROM
    (
        SELECT id_bioguide, min(term_start) as first_term
        FROM legislators_terms
        WHERE term_type = 'rep'
        GROUP BY 1
    ) b
    JOIN legislators_terms c on b.id_bioguide = c.id_bioguide
    and c.term_type = 'sen' and c.term_start > b.first_term
    GROUP BY 1
) bb on aa.cohort_century = bb.cohort_century
;

cohort_century  pct_5_yrs  pct_10_yrs  pct_15_yrs 
--------------  ---------  ----------  ---------- 
18              0.0502     0.0970      0.1438
19              0.0088     0.0244      0.0409
20              0.0100     0.0348      0.0478
21              0.0400     0.0764      0.0873

通过这个输出,我们可以看到代表成为参议员的份额随时间的演变,无论是在每个群体内部还是跨群体之间。除了表格格式外,将输出图形化通常会显示出有趣的趋势。在图 4-12 中,基于世纪的群体被替换为基于第一个十年的群体,并展示了 10 年和 20 年的趋势。在美国新立法的最初几十年内,代表转为参议员的情况显然与后来的模式有所不同。

图 4-12. 每个由起始十年定义的群体代表后来成为参议员的份额趋势

在一个固定时间段内找到重复的行为模式对比各个群体是一个有用的工具。特别是当行为是间歇性的,比如购买行为或内容或服务消费时。在下一节中,我们将看看如何计算一个实体不仅是否有后续行动,还有多少后续行动,并通过累积计算来汇总它们。

累积计算

累积群体分析可用于建立累积生命周期价值,也称为客户生命周期价值(CLTV 和 LTV 缩写可互换使用),并监测更新的群体,以便能够预测他们的完整 LTV。这是可能的,因为早期行为通常与长期行为高度相关。在最初使用某项服务的几天或几周内经常返回的用户,往往最有可能长期保持在服务中。在早期购买第二或第三次的客户通常会在较长时期内继续购买。在第一个月或第一年后续费的订阅者通常也会在许多后续的月份或年份内继续停留。

在本节中,我将主要讨论客户的收入生成活动,但这种分析也可以应用于客户或实体产生成本的情况,例如产品退货、支持交互或使用医疗服务。

在累积计算中,我们更少关心实体在特定日期是否执行了某项操作,而更关心截至特定日期的总数。在这类分析中使用的累积计算通常是countsum。我们将再次使用时间框概念,以确保对各队伍之间进行苹果对苹果的比较。让我们来看看在首次term_start后 10 年内开始的任期数,通过世纪和首次任期类型来分组立法者:

SELECT date_part('century',a.first_term) as century
,first_type
,count(distinct a.id_bioguide) as cohort
,count(b.term_start) as terms
FROM
(
    SELECT distinct id_bioguide
    ,first_value(term_type) over (partition by id_bioguide 
                                  order by term_start) as first_type
    ,min(term_start) over (partition by id_bioguide) as first_term
    ,min(term_start) over (partition by id_bioguide) 
     + interval '10 years' as first_plus_10
    FROM legislators_terms
) a
LEFT JOIN legislators_terms b on a.id_bioguide = b.id_bioguide 
and b.term_start between a.first_term and a.first_plus_10
GROUP BY 1,2
;

century  first_type  cohort  terms
-------  ----------  ------  -----
18       rep         297     760
18       sen         71      101
19       rep         5744    12165
19       sen         555     795
20       rep         4473    16203
20       sen         618     1008
21       rep         683     2203
21       sen         77      118

最大的队伍是 19 世纪首次当选的代表队,但最大届数队伍是在 10 年内首次当选的 20 世纪代表队。这种计算方法有助于理解一个队伍对组织的整体贡献。总销售额或总重复购买可以是有价值的度量标准。通常,我们希望对每个实体的贡献进行归一化理解。我们可能想做的计算包括每人平均行动次数,平均订单价值(AOV),订单中的商品数以及客户的订单数。为了按队伍规模进行归一化,只需除以起始队伍,这在之前处理保留率、生存率和回访率时已经做过。在这里,我们不仅这样做,还将结果转换为表格形式以便于比较:

SELECT century
,max(case when first_type = 'rep' then cohort end) as rep_cohort
,max(case when first_type = 'rep' then terms_per_leg end) 
 as avg_rep_terms
,max(case when first_type = 'sen' then cohort end) as sen_cohort
,max(case when first_type = 'sen' then terms_per_leg end) 
 as avg_sen_terms
FROM
(
    SELECT date_part('century',a.first_term) as century
    ,first_type
    ,count(distinct a.id_bioguide) as cohort
    ,count(b.term_start) as terms
    ,count(b.term_start) 
     / count(distinct a.id_bioguide) as terms_per_leg
    FROM
    (
        SELECT distinct id_bioguide
        ,first_value(term_type) over (partition by id_bioguide 
                                      order by term_start
                                      ) as first_type
        ,min(term_start) over (partition by id_bioguide) as first_term
        ,min(term_start) over (partition by id_bioguide) 
         + interval '10 years' as first_plus_10
        FROM legislators_terms
    ) a
    LEFT JOIN legislators_terms b on a.id_bioguide = b.id_bioguide 
    and b.term_start between a.first_term and a.first_plus_10
    GROUP BY 1,2
) aa
GROUP BY 1
;

century  rep_cohort  avg_rep_terms  sen_cohort  avg_sen_terms
-------  ----------  -------------  ----------  -------------
18       297         2.6            71          1.4
19       5744        2.1            555         1.4
20       4473        3.6            618         1.6
21       683         3.2            77          1.5

通过按队伍规模归一化的累积任期数,我们现在可以确认 20 世纪首次当选的代表平均拥有最多的任期数,而 19 世纪开始的人则平均拥有最少的任期数。参议员比他们的代表同行拥有更少但更长的任期,同样,那些 20 世纪开始的人平均拥有最多的任期数。

累积计算通常用于计算客户生命周期价值。LTV 通常使用货币衡量指标进行计算,如客户终身支出的总金额,或者客户在其生命周期内产生的总毛利(收入减去成本)。为了比较各个队伍之间的差异,通常选择“终身”来反映平均客户生命周期,或者方便分析的周期,如 3 年、5 年或 10 年。立法者数据集不包含财务指标,但在任何前述的 SQL 代码中加入金额值是直接的。幸运的是,SQL 是一种足够灵活的语言,我们可以根据各种分析问题调整这些模板。

队列分析包括一系列技术,可用于回答与时间相关的行为问题,以及各种属性如何导致不同群体之间差异的问题。存活率、回头率和累积计算都可以阐明这些问题。了解了队列的行为方式后,我们常常需要重新关注随时间变化的队列组成或混合情况,了解这如何影响总的保留率、存活率、回头率或累积值,从而使这些度量值与个体队列之间的差异出现意外的情况。

通过队列镜头的横断面分析

在这一章中,我们迄今为止一直在研究队列分析。我们通过保留率、存活率、回头率和累积行为分析跨时间追踪队列的行为。然而,这些分析面临的一个挑战是,即使它们使得队列内部的变化变得容易发现,但很难发现客户或用户基础整体组成的变化。

混合转变,即客户或用户基础随时间发生的组成变化,也可能发生,使后来的队列与之前的不同。混合转变可能是由于国际扩展、在有机和付费获取策略之间的转变,或是从小众爱好者观众转向更广泛的大众市场观众。沿着这些疑似线路创建额外的队列或段可以帮助诊断是否正在发生混合转变。

队列分析可以与横断面分析进行对比,后者比较个体或群体在单一时间点上的情况。例如,横断面研究可以将教育年限与当前收入进行相关性分析。积极的一面是,收集用于横断面分析的数据集通常更容易,因为不需要时间序列。横断面分析可以提供深刻的见解,为进一步研究生成假设。然而,消极的一面是,通常存在一种叫做存活偏差的选择偏差,可能导致错误的结论。

队列分析是通过将起始队列的所有成员纳入分析来克服存活偏差的一种方法。我们可以从队列分析中获取一系列横断面,以了解实体的组合如何随时间变化而变化。在任何给定日期,各种队列的用户都在场。我们可以像沉积物的层层一样使用横断面分析来检查它们,以揭示新的见解。在下一个示例中,我们将为数据集中每年的每个立法者队列的份额创建一个时间序列。

第一步是通过将legislators表连接到date_dimWHERE date_dim中的日期位于每个任期的起始日期和结束日期之间,来找到每年在任的立法者数量。在这里,我们使用每年的 12 月 31 日来找出每年末在任的立法者:

SELECT b.date, count(distinct a.id_bioguide) as legislators
FROM legislators_terms a
JOIN date_dim b on b.date between a.term_start and a.term_end
and b.month_name = 'December' and b.day_of_month = 31
and b.year <= 2019
GROUP BY 1
;

date        legislators
----------  -----------
1789-12-31  89
1790-12-31  95
1791-12-31  99
...         ...

接下来,我们根据计算的first_term加入世纪队列准则:

SELECT b.date
,date_part('century',first_term) as century
,count(distinct a.id_bioguide) as legislators
FROM legislators_terms a
JOIN date_dim b on b.date between a.term_start and a.term_end
 and b.month_name = 'December' and b.day_of_month = 31
 and b.year <= 2019
JOIN
(
    SELECT id_bioguide, min(term_start) as first_term
    FROM legislators_terms
    GROUP BY 1
) c on a.id_bioguide = c.id_bioguide        
GROUP BY 1,2
;

date        century  legislators
----------  -------  -----------
1789-12-31  18       89
1790-12-31  18       95
1791-12-31  18       99
...         ...      ...

最后,我们计算每年世纪队列代表的总立法者百分比。这可以通过几种方式完成,具体取决于所需输出的形状。第一种方式是保留每个datecentury组合的行,并在百分比计算的分母中使用sum窗口函数:

SELECT date
,century
,legislators
,sum(legislators) over (partition by date) as cohort
,legislators / sum(legislators) over (partition by date) 
 as pct_century
FROM
(
    SELECT b.date
    ,date_part('century',first_term) as century
    ,count(distinct a.id_bioguide) as legislators
    FROM legislators_terms a
    JOIN date_dim b on b.date between a.term_start and a.term_end
    and b.month_name = 'December' and b.day_of_month = 31
    and b.year <= 2019
    JOIN
    (
        SELECT id_bioguide, min(term_start) as first_term
        FROM legislators_terms
        GROUP BY 1
    ) c on a.id_bioguide = c.id_bioguide        
    GROUP BY 1,2
) a
;

date        century  legislators  cohort  pct_century
----------  -------  -----------  ------  -----------
2018-12-31  20       122          539     0.2263
2018-12-31  21       417          539     0.7737
2019-12-31  20       97           537     0.1806
2019-12-31  21       440          537     0.8194
...         ...      ...          ...     ...

第二种方法会导致每年一行,每个世纪一个列,这种表格形式可能更容易扫描趋势:

SELECT date
,coalesce(sum(case when century = 18 then legislators end)
          / sum(legislators),0) as pct_18
,coalesce(sum(case when century = 19 then legislators end)
          / sum(legislators),0) as pct_19
,coalesce(sum(case when century = 20 then legislators end)
          / sum(legislators),0) as pct_20
,coalesce(sum(case when century = 21 then legislators end)
          / sum(legislators),0) as pct_21
FROM
(
    SELECT b.date
    ,date_part('century',first_term) as century
    ,count(distinct a.id_bioguide) as legislators
    FROM legislators_terms a
    JOIN date_dim b on b.date between a.term_start and a.term_end
     and b.month_name = 'December' and b.day_of_month = 31
     and b.year <= 2019
    JOIN
    (
        SELECT id_bioguide, min(term_start) as first_term
        FROM legislators_terms
        GROUP BY 1
    ) c on a.id_bioguide = c.id_bioguide        
    GROUP BY 1,2
) aa
GROUP BY 1
;

date        pct_18  pct_19  pct_20  pct_21
----------  ------  ------  ------  ------
2017-12-31  0       0       0.2305  0.7695
2018-12-31  0       0       0.2263  0.7737
2019-12-31  0       0       0.1806  0.8193
...         ...     ...     ...     ...

我们可以像在图表 4-13 中那样绘制输出,以查看新一代立法者逐渐超过老一代,直到它们自己被新一代取代。

图表 4-13。按首次当选世纪计算的每年立法者百分比

我们可以根据first_term而不是任期进行队列化。在不同时间点找到相对新的客户、中期任期客户和长期客户的份额可能很有见地。让我们看看国会议员的任期如何随时间变化。

第一步是计算每位立法者每年在职的累积任期。由于立法者可能因选举失败或其他原因离职,会有任期之间的空白期,我们首先在子查询中找到每年底时立法者在职的年份。然后我们将使用count窗口函数,窗口涵盖该立法者的所有先前行和当前行:

SELECT id_bioguide, date
,count(date) over (partition by id_bioguide 
                   order by date rows between 
                   unbounded preceding and current row
                   ) as cume_years
FROM
(
    SELECT distinct a.id_bioguide, b.date
    FROM legislators_terms a
    JOIN date_dim b on b.date between a.term_start and a.term_end
     and b.month_name = 'December' and b.day_of_month = 31
     and b.year <= 2019
) aa
;

id_bioguide  date        cume_years
-----------  ----------  ----------
A000001      1951-12-31  1
A000001      1952-12-31  2
A000002      1947-12-31  1
A000002      1948-12-31  2
A000002      1949-12-31  3
...          ...         ...

接下来,对于每个datecume_years的组合,计算立法者的数量以创建分布:

SELECT date, cume_years
,count(distinct id_bioguide) as legislators
FROM
(
SELECT id_bioguide, date
,count(date) over (partition by id_bioguide 
                   order by date rows between 
                   unbounded preceding and current row
                   ) as cume_years
FROM
(
    SELECT distinct a.id_bioguide, b.date
    FROM legislators_terms a
    JOIN date_dim b on b.date between a.term_start and a.term_end
     and b.month_name = 'December' and b.day_of_month = 31
     and b.year <= 2019
    GROUP BY 1,2
    ) aa
) aaa
GROUP BY 1,2
;

date         cume_years  legislators
-----------  ----------  ----------
1789-12-31   1           89
1790-12-31   1           6
1790-12-31   2           89
1791-12-31   1           37
...          ...         ...

在为每年的任期计算百分比并调整展示格式之前,我们可能需要考虑对任期进行分组。我们目前的结果快速分析显示,在某些年份,几乎有 40 个不同的任期。这可能会导致可视化和解释变得困难:

SELECT date, count(*) as tenures
FROM 
(
    SELECT date, cume_years
    ,count(distinct id_bioguide) as legislators
    FROM
    (
        SELECT id_bioguide, date
        ,count(date) over (partition by id_bioguide 
                           order by date rows between 
                           unbounded preceding and current row
                           ) as cume_years
        FROM
        (
            SELECT distinct a.id_bioguide, b.date
            FROM legislators_terms a
            JOIN date_dim b 
             on b.date between a.term_start and a.term_end
             and b.month_name = 'December' and b.day_of_month = 31
             and b.year <= 2019
            GROUP BY 1,2
        ) aa
    ) aaa
    GROUP BY 1,2
) aaaa
GROUP BY 1
;

date         tenures
-----------  -------
1998-12-31   39
1994-12-31   39
1996-12-31   38
...          ...

因此,我们可能需要对数值进行分组。对任期进行分组没有唯一正确的方法。如果有组织定义的任期组,可以直接使用。否则,我通常尝试将其分成三到五个大致相等大小的组。在这里,我们将任期分为四个队列,其中cume_years小于或等于 4 年、5 到 10 年之间、11 到 20 年之间,以及大于或等于 21 年:

SELECT date, tenure
,legislators / sum(legislators) over (partition by date) 
 as pct_legislators 
FROM
(
    SELECT date
    ,case when cume_years <= 4 then '1 to 4'
          when cume_years <= 10 then '5 to 10'
          when cume_years <= 20 then '11 to 20'
          else '21+' end as tenure
    ,count(distinct id_bioguide) as legislators
    FROM
    (
        SELECT id_bioguide, date
        ,count(date) over (partition by id_bioguide 
                           order by date rows between 
                           unbounded preceding and current row
                           ) as cume_years
        FROM
        (
            SELECT distinct a.id_bioguide, b.date
            FROM legislators_terms a
            JOIN date_dim b 
             on b.date between a.term_start and a.term_end
             and b.month_name = 'December' and b.day_of_month = 31
             and b.year <= 2019
            GROUP BY 1,2
        ) a
    ) aa
    GROUP BY 1,2
) aaa
;

date        tenure    pct_legislators
----------  -------   ---------------
2019-12-31  1 to 4    0.2998
2019-12-31  5 to 10   0.3203
2019-12-31  11 to 20  0.2011
2019-12-31  21+       0.1788
...         ...       ...

在图表 4-14 中展示的结果表明,在国家早期,大多数立法者任期非常短。近年来,任期超过 21 年的立法者比例逐渐增加。还有有趣的周期性增加的 1 至 4 年任期立法者,可能反映了政治趋势的变化。

图 4-14. 在职年限的立法者百分比

人群的任何时间点的横截面由多个队列的成员组成。创建这些横截面的时间序列是分析趋势的另一种有趣方式。将这一点与留存的见解结合起来可以提供组织中趋势的更全面图像。

结论

跨时间队列分析是一种有用的方法,用于调查群体随时间变化的方式,无论是从留存、重复行为还是累积行为的角度来看。队列分析是回顾性的,通过回顾具有固有属性或行为派生属性的人群来进行。通过这种类型的分析可以找到有趣且有希望的相关性。然而,俗话说,相关不等于因果关系。要确定实际的因果关系,随机实验是金标准。第七章将深入探讨实验分析。

然而,在我们转向实验之前,我们还有其他几种类型的分析需要涵盖。接下来我们将涵盖文本分析:文本分析的组成部分经常出现在其他分析中,它本身也是分析的一个有趣方面。

第五章:文本分析

在过去的两章中,我们探讨了日期和数字的应用,包括时间序列分析和队列分析。但是数据集通常不仅仅是数字值和相关时间戳。从定性属性到自由文本,字符字段通常包含潜在有趣的信息。尽管数据库擅长于数字计算,如计数、求和和平均值,但它们在处理文本数据方面也非常擅长。

我将从提供 SQL 擅长的文本分析任务概述开始这一章节,以及其他更适合使用其他编程语言的任务。接下来,我会介绍我们的 UFO 目击数据集。然后我们将进入编码部分,涵盖文本特征和分析、使用 SQL 解析数据、进行各种转换、从部分构建新文本,最后在更大的文本块中查找元素,包括使用正则表达式。

为什么要使用 SQL 进行文本分析?

在每天产生的海量数据中,有很大一部分是文本:单词、句子、段落,甚至更长的文档。用于分析的文本数据可以来自各种来源,包括人类或计算机应用程序填充的描述符、日志文件、支持票据、客户调查、社交媒体帖子或新闻订阅。数据库中的文本从结构化(数据位于不同表字段中,具有不同的含义)到半结构化(数据位于不同列中,但可能需要解析或清理才能有用)再到主要是非结构化(长 VARCHAR 或 BLOB 字段保存需要在进一步分析之前进行广泛结构化的任意长度字符串)。幸运的是,SQL 具有许多有用的函数,可以结合使用以完成各种文本结构化和分析任务。

什么是文本分析?

文本分析是从文本数据中提取意义和洞察的过程。文本分析大致分为两类,可以通过输出是定性还是定量来区分。定性分析,也可以称为文本分析,旨在理解和综合从单个文本或一组文本中获得的含义,通常应用其他知识或独特的结论。这项工作通常由记者、历史学家和用户体验研究人员完成。定量分析也旨在从文本数据中综合信息,但其输出是定量的。任务包括分类和数据提取,并且分析通常以计数或频率的形式进行,通常随时间趋势变化。SQL 更适合于定量分析,因此本章的其余部分将关注此内容。然而,如果有机会与专注于第一类文本分析的同行合作,请务必利用他们的专业知识。将定性与定量结合是获得新洞见并说服不情愿的同事的好方法。

文本分析包括几个目标或策略。第一个是文本提取,其中必须从周围文本中提取有用的数据片段。另一个是分类,其中从文本数据中提取或解析信息,以便为数据库中的行分配标签或类别。另一种策略是情感分析,其目标是理解作者的情绪或意图,从负面到正面的范围。

尽管文本分析已经存在一段时间,但随着机器学习的出现以及处理大量文本数据通常需要的计算资源,对这一领域的兴趣和研究已经蓬勃发展。自然语言处理(NLP)在识别、分类甚至生成全新文本数据方面取得了巨大进展。人类语言极为复杂,包括不同的语言和方言、语法和俚语,更不用说成千上万的单词,有些单词具有重叠的含义或微妙地修改其他单词的含义。正如我们将看到的,SQL 在某些形式的文本分析上表现良好,但对于其他更高级的任务,存在更适合的语言和工具。

为什么 SQL 是进行文本分析的好选择

使用 SQL 进行文本分析有许多好处。其中一个最明显的好处是当数据已经在数据库中时。现代数据库具有大量的计算能力,可以用于文本任务,除了我们迄今讨论过的其他任务。将数据移动到平面文件中,再用其他语言或工具进行分析是耗时的,因此在数据库内尽可能多地使用 SQL 进行工作具有优势。

如果数据尚未存储在数据库中,对于相对大的数据集,将数据移动到数据库可能是值得的。与电子表格相比,数据库在处理多条记录的转换时更为强大。SQL 不像电子表格那样容易出错,因为不需要复制和粘贴,原始数据保持不变。数据可能会被UPDATE命令意外地更改,但这很难发生。

SQL 在最终目标是某种形式的量化时也是一个不错的选择。计数多少支持票包含关键短语以及解析大文本中的类别,这些都是 SQL 发挥作用的好例子。SQL 擅长清理和结构化文本字段。清理包括删除额外字符或空格,修正大小写,以及标准化拼写。结构化涉及从其他字段中提取或推导元素创建新列,或从不同位置存储的部分构建新字段。字符串函数可以嵌套或应用于其他函数的结果,几乎可以进行任何可能需要的操作。

用于文本分析的 SQL 代码可以简单也可以复杂,但它总是基于规则的。在基于规则的系统中,计算机遵循一组规则或指令——既不多也不少。这与机器学习形成对比,后者根据数据进行调整。规则之所以好,是因为它们易于人类理解。它们以代码形式写下,并可以检查以确保它们产生期望的输出。规则的缺点在于它们可能变得冗长和复杂,特别是在需要处理许多不同情况时。这也可能使它们难以维护。如果输入到列中的数据的结构或类型发生变化,规则集就需要更新。我不止一次地以为自己开始用的是一个看起来简单的 CASE 语句,只有 4 或 5 行,结果随着应用程序的变化它就增长到 50 或 100 行。规则可能仍然是正确的方法,但与开发团队保持同步变更是个好主意。

最后,当您事先知道要查找的内容时,SQL 是一个不错的选择。它有许多强大的功能,包括正则表达式,允许您搜索、提取或替换特定信息。例如,“有多少评论者提到‘短电池寿命’?”这是 SQL 可以帮助您回答的问题。然而,“为什么这些客户生气?”就不会那么容易了。

SQL 不适合的情况

SQL 基本上允许您利用数据库的力量,应用一组规则(尽管通常是强大的规则)对一组文本进行处理,使其在分析中更加有用。SQL 显然不是文本分析的唯一选择,还有许多情况并非最佳选择。了解这些情况是很有用的。

第一类涵盖了更适合人类处理的用例。当数据集非常小或非常新时,手动标记可能更快且更具信息性。此外,如果目标是阅读所有记录并得出关键主题的定性总结,选择人类更为合适。

第二类情况是当需要搜索和检索包含文本字符串的特定记录,并且需要低延迟时。像 Elasticsearch 或 Splunk 这样的工具已经开发出来为这些用例索引字符串。在 SQL 和数据库中,性能通常是一个问题;这是我们通常尝试将数据结构化为可以更容易通过数据库引擎搜索的离散列的主要原因之一。

第三类包括更广泛的 NLP 类别中的任务,其中机器学习方法以及运行它们的语言(如 Python)是更好的选择。情感分析,用于分析文本中的正面或负面情感范围,仅能以简单方式通过 SQL 处理。例如,“爱”和“恨”可以被提取并用于分类记录,但考虑到可以表达正面和负面情绪的词语范围,以及否定这些词语的各种方式,使用 SQL 几乎不可能创建一个规则集来处理所有情况。词性标注,即将文本中的单词标记为名词、动词等,最好使用 Python 中提供的库来处理。语言生成,即根据从示例文本中学到的内容创建全新文本,是另一个最好用其他工具处理的例子。我们将看到如何通过连接数据片段来创建新文本,但 SQL 仍受规则约束,不会自动从数据集中学习并适应新的示例。

现在,我们已经讨论了使用 SQL 进行文本分析的许多充分理由,以及需要避免的用例类型,接下来让我们在深入研究 SQL 代码之前,先看看我们将在示例中使用的数据集。

UFO 目击数据集

在本章的示例中,我们将使用由国家不明飞行物报告中心编制的 UFO 目击数据集。该数据集包含从 2006 年到 2020 年间发布的约 95,000 条报告。这些报告来自通过在线表单输入信息的个人。

我们将使用的表是 ufo,它只有两列。第一列是一个名为 sighting_report 的复合列,其中包含目击事件发生、报告以及发布的详细信息。它还包含有关目击事件的位置、形状和持续时间的元数据。第二列是一个名为 description 的文本字段,其中包含事件的完整描述。图示 5-1 显示了数据的一个示例。

图 5-1. ufo 表的示例

通过本章的示例和讨论,我将展示如何将第一列解析为结构化的日期和描述符。我还将展示如何对description字段执行各种分析。如果我持续处理这些数据,我可能会考虑创建一个 ETL 管道,这是一个定期以相同方式处理数据并将结果存储在新表中的作业。然而,在本章的示例中,我们将继续使用原始表格。

让我们进入代码,从 SQL 开始探索和特征化来自目击事件的文本。

文本特性

数据库中最灵活的数据类型是 VARCHAR,因为几乎任何数据都可以放入这种类型的字段中。因此,数据库中的文本数据呈现出各种形状和大小。与其他数据集一样,分析和特征化数据是我们首先要做的事情之一。从那里我们可以制定清理和解析分析可能需要的计划。

我们了解文本数据的一种方法是查找每个值中的字符数,可以使用length函数(或某些数据库中的len)。此函数以字符串或字符字段作为参数,并类似于其他语言和电子表格程序中的函数:

SELECT length('Sample string');

length
------
13

我们可以创建字段长度的分布来了解典型长度以及是否存在需要特殊处理的极端异常值:

SELECT length(sighting_report), count(*) as records
FROM ufo
GROUP BY 1
ORDER BY 1
;

length  records
------  -------
90      1
91      4
92      8
...     ...

我们可以在图 5-2 中看到,大多数记录的长度大约在 150 到 180 个字符之间,少数小于 140 或大于 200 个字符。description字段的长度范围从 5 到 64,921 个字符不等。我们可以假设即使在进行任何额外的分析之前,这个字段的变化也是非常多样的。

图 5-2. *ufo*表第一列字段长度的分布

让我们看一看sighting_report列的几个示例行。在查询工具中,我可能会浏览大约一百行左右以熟悉内容,但这些行代表了列中的值的典型情况:

Occurred : 3/4/2018 19:07 (Entered as : 03/04/18 19:07)Reported: 3/6/2018 7:05:12
PM 19:05Posted: 3/8/2018Location: Colorado Springs, COShape: LightDuration:3
minutes
Occurred : 10/16/2017 21:42 (Entered as : 10/16/2017 21:42)Reported: 3/6/2018
5:09:47 PM 17:09Posted: 3/8/2018Location: North Dayton, OHShape: SphereDuration:~5
minutes
Occurred : 2/15/2018 00:10 (Entered as : 2/15/18 0:10)Reported: 3/6/2018 
6:19:54 PM 18:19Posted: 3/8/2018Location: Grand Forks, NDShape: SphereDuration:
5 seconds

这些数据可以被称为半结构化或超负荷的。不能直接用于分析,但这里显然存储了明确的信息片段,并且在行之间的模式相似。例如,每一行都有“Occurred”后跟着类似时间戳的内容,“Location”后跟着地点,“Duration”后跟着时间长度。

注意

数据可能因各种原因而最终存在于过多字段中,但我见过两种常见情况。一种情况是源系统或应用程序中没有足够的字段可用于存储所需的所有属性,因此多个属性输入到同一字段中。另一种情况是数据存储在应用程序中的 JSON blob 中,以适应稀疏属性或频繁添加新属性。虽然这两种情况在分析角度上不理想,但只要有一致的结构,通常可以通过 SQL 处理这些情况。

我们的下一步是通过将其解析为多个新字段使此字段更易于使用,每个字段都包含单个信息片段。此过程中的步骤包括:

  • 规划所需的输出字段(或字段)

  • 应用解析函数

  • 应用转换,包括数据类型转换

  • 当应用于整个数据集时,请检查结果,因为通常会有一些记录不符合模式

  • 重复这些步骤,直到数据处于所需的列和格式中

我们将从sighting_report中解析出的新列是occurredentered_asreportedpostedlocationshapeduration。接下来,我们将学习解析函数,并开始构造ufo数据集。

文本解析

使用 SQL 解析数据是从文本值中提取片段以使其更有用于分析的过程。解析将数据分割成我们想要的部分和“其余所有内容”,尽管通常我们的代码只返回我们想要的部分。

最简单的解析函数从字符串的开头或结尾返回固定数量的字符。left函数从字符串的左侧或开头返回字符,而right函数从字符串的右侧或结尾返回字符。除此之外,它们的工作方式相同,第一个参数是要解析的值,第二个参数是字符数。任一参数可以是数据库字段或计算,允许动态结果:

SELECT left('The data is about UFOs',3) as left_digits
,right('The data is about UFOs',4) as right_digits
;

left_digits  right_digits
-----------  -----
The          UFOs

ufo数据集中,我们可以使用left函数解析出第一个单词“Occurred”:

SELECT left(sighting_report,8) as left_digits
,count(*)
FROM ufo
GROUP BY 1
;

left_digits  count
-----------  -----
Occurred     95463

我们可以确认所有记录都以此单词开头,这是个好消息,因为这意味着至少模式的这一部分是一致的。但是,我们真正想要的是发生了什么的值,而不是单词本身,所以让我们再试一次。在第一个示例记录中,发生的时间戳结束于第 25 个字符。为了删除“Occurred”并保留实际时间戳,我们可以使用right函数返回最右侧的 14 个字符。请注意,rightleft函数是嵌套的—right函数的第一个参数是left函数的结果:

SELECT right(left(sighting_report,25),14) as occurred
FROM ufo
;

occurred
--------------
3/4/2018 19:07
10/16/2017 21:
2/15/2018 00:1
...

虽然这可以为第一条记录返回正确结果,但不幸的是,它无法处理具有两位数月份或日期值的记录。我们可以增加leftright函数返回的字符数,但结果将包含第一条记录的太多字符。

leftright函数对于提取字符串的固定长度部分非常有用,例如我们提取单词“Occurred”的操作,但对于更复杂的模式,名为split_part的函数更加实用。这个函数的想法是基于分隔符将字符串拆分为部分,然后允许您选择特定的部分。分隔符是用于指定文本或其他数据区域边界的一个或多个字符。逗号分隔符和制表符分隔符可能是最常见的,因为它们用于文本文件(如.csv.tsv.txt文件)中表示列的起始和结束位置。但是,任何字符序列都可以使用,这在我们的解析任务中会非常有用。函数的形式是:

split_part(string or field name, delimiter, index)

索引是要返回的文本相对于分隔符的位置。因此,索引 = 1 返回分隔符的第一个实例左侧的所有文本,索引 = 2 返回第一个和第二个分隔符之间的文本(或者如果分隔符仅出现一次,则返回分隔符右侧的所有文本),依此类推。没有零索引,值必须是正整数:

SELECT split_part('This is an example of an example string'
                  ,'an example'
                  ,1);

split_part
----------
This is 

SELECT split_part('This is an example of an example string'
                  ,'an example'
                  ,2);

split_part
----------
 of
提示

MySQL 有一个substring_index函数,而 SQL Server 根本没有split_part函数。

注意文本中的空格将保留,除非指定为分隔符的一部分。让我们看看如何解析sighting_report列的元素。作为提醒,样本值如下所示:

Occurred : 6/3/2014 23:00 (Entered as : 06/03/14 11:00)Reported: 6/3/2014 10:33:24
PM 22:33Posted: 6/4/2014Location: Bethesda, MDShape: LightDuration:15 minutes

我们希望查询返回的值是“Occurred : ”和“ (Entered”之间的文本。也就是说,我们想要字符串“6/3/2014 23:00”。检查样本文本,“Occurred :”和“(Entered”只出现一次。冒号(:)多次出现,既用于将标签与值分隔开,又用于时间戳中间。这可能使得使用冒号进行解析变得棘手。开括号字符只出现一次。我们可以选择指定作为分隔符的内容,选择较长的字符串或仅包含拆分字符串所需的最少字符。我倾向于稍微冗长一些,以确保我确实获得我想要的那部分内容,但这真的取决于情况。

首先,在“Occurred : ”上拆分sighting_report,并检查结果:

SELECT split_part(sighting_report,'Occurred : ',2) as split_1
FROM ufo
;

split_1
--------------------------------------------------------------
6/3/2014 23:00 (Entered as : 06/03/14 11:00)Reported: 6/3/2014 10:33:24 PM
22:33Posted: 6/4/2014Location: Bethesda, MDShape: LightDuration:15 minutes

我们已成功移除标签,但仍有大量多余文本。让我们在“ (Entered”处拆分时检查结果:

SELECT split_part(sighting_report,' (Entered',1) as split_2
FROM ufo
;

split_2
-------------------------
Occurred : 6/3/2014 23:00

这更接近了,但结果中仍然有标签。幸运的是,嵌套使用split_part函数将仅返回所需的日期和时间值:

SELECT split_part(
         split_part(sighting_report,' (Entered',1)
         ,'Occurred : ',2) as occurred
FROM ufo
;

occurred
---------------
6/3/2014 23:00
4/25/2014 21:15
5/25/2014

现在结果包含了所需的值。再查看一些额外的行显示,两位数的日和月值已经适当处理,没有时间值的日期也是如此。事实证明,一些记录省略了“输入为”值,因此需要额外的拆分以处理标记为所需字符串末尾的“报告”标签的记录:

SELECT 
split_part(
  split_part(
    split_part(sighting_report,' (Entered',1)
    ,'Occurred : ',2)
    ,'Reported',1) as occurred
FROM ufo
;

occurred
---------------
6/24/1980 14:00
4/6/2006 02:05
9/11/2001 09:00
...

用 SQL 代码解析出来的最常见的 发生 值在 图 5-3 中被绘制。

图 5-3. UFO 目击事件中前 10 个最常见的 发生
小贴士

找到适用于数据集中所有值的函数集是文本解析中最难的部分之一。通常需要多轮尝试和逐步分析结果来正确处理它。

下一步是应用类似的解析规则,以提取其他所需字段,使用起始和结束定界符来隔离字符串的相关部分。最终查询在几个值中多次使用 split_part,每个值都有不同的参数:

SELECT 
  split_part(
    split_part(
      split_part(sighting_report,' (Entered',1)
      ,'Occurred : ',2)
    ,'Reported',1) as occurred
,split_part(
  split_part(sighting_report,')',1)
    ,'Entered as : ',2) as entered_as
,split_part(
  split_part(
    split_part(
      split_part(sighting_report,'Post',1)
      ,'Reported: ',2)
    ,' AM',1)
  ,' PM',1) as reported
,split_part(split_part(sighting_report,'Location',1),'Posted: ',2) 
  as posted
,split_part(split_part(sighting_report,'Shape',1),'Location: ',2) 
  as location
,split_part(split_part(sighting_report,'Duration',1),'Shape: ',2) 
  as shape
,split_part(sighting_report,'Duration:',2) as duration
FROM ufo
;

occurred   entered_as   reported  posted   location     shape       duration
--------   ----------   --------  -------  -----------  ---------   -----------
7/4/2...   07/04/2...   7/5...    7/5/...  Columbus...  Formation   15 minutes
7/4/2...   07/04/2...   7/5...    7/5/...  St. John...  Circle      2-3 minutes
7/4/2...   07/7/1...    7/5...    7/5/...  Royal Pa...  Circle      3 minutes
...        ...          ...       ...      ...          ...         ...

通过这种 SQL 解析,数据现在处于更加结构化和可用的格式中。然而,在我们完成之前,还有一些转换可以进一步清理数据。我们将接下来查看这些字符串转换函数。

文本转换

转换会以某种方式更改字符串值。我们在 第三章 中看到了许多日期和时间戳转换函数。SQL 中有一组专门处理字符串值的函数。这些对于处理解析后的数据非常有用,也适用于需要调整或清理分析的任何数据库文本数据。

最常见的转换之一是更改大小写的转换。upper 函数将所有字母转换为大写形式,而 lower 函数则将所有字母转换为小写形式。例如:

SELECT upper('Some sample text');

upper
----------------
SOME SAMPLE TEXT

SELECT lower('Some sample text');

lower
----------------
some sample text

这些方法对于标准化可能以不同方式输入的值非常有用。例如,任何人都会意识到,“加利福尼亚”,“caLiforNia”和“CALIFORNIA”指的是同一个州,但数据库会将它们视为不同的值。如果我们按这些值按州统计 UFO 目击事件,我们会得到三条加利福尼亚州的记录,导致分析结论不正确。将它们全部转换为大写或小写字母可以解决这个问题。一些数据库,包括 Postgres,具有 initcap 函数,该函数会将字符串中每个单词的首字母大写。这对于专有名词(例如州名)非常有用:

SELECT initcap('caLiforNia'), initcap('golden gate bridge');

initcap     initcap
----------  ------------------
California  Golden Gate Bridge

我们解析的数据集中的 shape 字段包含一个全大写的值,“TRIANGULAR”。为了清理并将其与其他只有首字母大写的值标准化,应用 initcap 函数:

SELECT distinct shape, initcap(shape) as shape_clean
FROM
(
    SELECT split_part(
             split_part(sighting_report,'Duration',1)
             ,'Shape: ',2) as shape
    FROM ufo
) a
;

shape       shape_clean
----------  -----------
...         ... 
Sphere      Sphere
TRIANGULAR  Triangular
Teardrop    Teardrop
...         ...

每种形状的目击次数显示在图 5-4 中。光是远远最常见的形状,其次是圆形和三角形。一些目击没有报告形状,因此在图表中也会出现空值计数。

图 5-4。UFO 目击中形状的频率

另一个有用的转换函数是称为trim的函数,它删除字符串开头和结尾的空格。在解析较长字符串的值或将数据从一个应用程序复制到另一个应用程序时,额外的空白字符是常见问题。例如,我们可以使用trim函数在以下字符串中的“California”之前去除前导空格:

SELECT trim('  California  ');

trim
----------
California

函数trim有一些可选参数,使其在各种数据清理挑战中非常灵活。首先,它可以从字符串的开头或末尾(或两者)移除字符。从两端修剪是默认设置,但其他选项可以通过leadingtrailing指定。另外,trim可以移除任何字符,而不仅仅是空白字符。例如,如果某个应用程序出于某种原因在每个州名开头放置了美元符号($),我们可以使用trim来移除它:

SELECT trim(leading '$' from '$California');

duration字段中的一些值有前导空格,因此应用trim将产生更清晰的输出:

SELECT duration, trim(duration) as duration_clean
FROM
(
    SELECT split_part(sighting_report,'Duration:',2) as duration
    FROM ufo
) a
;

duration               duration_clean
---------------------  --------------------
 ~2 seconds            ~2 seconds
 15 minutes            15 minutes
 20 minutes (ongoing)  20 minutes (ongoing)

最常见持续时间的目击次数在图 5-5 中绘制。持续 1 到 10 分钟的目击很常见。一些目击没有报告持续时间,因此在图表中显示空值计数。

图 5-5。UFO 目击最常见的前 10 个持续时间

下一种转换类型是数据类型转换。这种转换类型在第二章中讨论,对于确保我们解析结果具有预期数据类型将非常有用。在我们的情况中,有两个字段应视为时间戳——occurredreported列,posted列应为日期类型。数据类型可以通过强制转换来更改,可以使用双冒号(::)运算符或CAST field as type语法。我们将entered_aslocationshapeduration的值保留为 VARCHAR:

SELECT occurred::timestamp
,reported::timestamp as reported
,posted::date as posted
FROM
(
    SELECT 
    split_part(
      split_part(
        split_part(sighting_report,' (Entered',1)
        ,'Occurred : ',2)
      ,'Reported',1) 
      as occurred   
    ,split_part(
      split_part(
        split_part(
          split_part(sighting_report,'Post',1)
          ,'Reported: ',2)
        ,' AM',1),' PM',1) 
      as reported
    ,split_part(
      split_part(sighting_report,'Location',1)
      ,'Posted: ',2) 
      as posted
    FROM ufo
) a
;

occurred             reported             posted
-------------------  -------------------  ----------
2015-05-24 19:30:00  2015-05-25 10:07:21  2015-05-29
2015-05-24 22:40:00  2015-05-25 09:09:09  2015-05-29
2015-05-24 22:30:00  2015-05-24 10:49:43  2015-05-29
...                  ...                  ...

数据的一个样本转换为新的格式。请注意,数据库将秒数添加到时间戳中,即使原始值中没有秒数,并且正确识别了以月/日/年(mm/dd/yyyy)格式存在的日期。¹ 然而,在将这些转换应用于整个数据集时存在问题。一些记录根本没有值,显示为空字符串,而一些记录具有时间值但没有与之关联的日期。尽管空字符串和 null 看起来包含相同的信息——什么都没有——但数据库对它们的处理方式不同。空字符串仍然是字符串,无法转换为另一种数据类型。通过使用 CASE 语句将所有不符合规范的记录设置为 null,可以使类型转换正常工作。由于我们知道日期必须至少包含八个字符(年份四位数字,月份和日期各一到两位数字,以及两个“-”或“/”字符),因此可以通过使用 CASE 语句将长度小于 8 的任何记录设置为 null 来实现这一点:

SELECT 
case when occurred = '' then null 
     when length(occurred) < 8 then null
     else occurred::timestamp 
     end as occurred
,case when length(reported) < 8 then null
      else reported::timestamp 
      end as reported
,case when posted = '' then null
      else posted::date  
      end as posted
FROM
(
    SELECT 
    split_part(
      split_part(
        split_part(sighting_report,'(Entered',1)
        ,'Occurred : ',2)
      ,'Reported',1) as occurred 
    ,split_part(
      split_part(
        split_part(
          split_part(sighting_report,'Post',1)
          ,'Reported: ',2)
        ,' AM',1)
      ,' PM',1) as reported
    ,split_part(
       split_part(sighting_report,'Location',1)
       ,'Posted: ',2) as posted
    FROM ufo
) a
;

occurred             reported             posted
-------------------  -------------------  ----------
1991-10-01 14:00:00  2018-03-06 08:54:22  2018-03-08
2018-03-04 19:07:00  2018-03-06 07:05:12  2018-03-08
2017-10-16 21:42:00  2018-03-06 05:09:47  2018-03-08
...                  ...                  ...

我将在本节中讨论的最后一个转换是replace函数。有时,在字段中有一个词、短语或其他字符串,我们想要将其更改为另一个字符串或完全删除。replace函数在这种情况下非常有用。它接受三个参数——原始文本、要查找的字符串和要替换的字符串:

replace(string or field, string to find, string to substitute)

所以,例如,如果我们想要将“未识别飞行物体”的引用更改为“UFO”,我们可以使用replace函数:

SELECT replace('Some unidentified flying objects were noticed
above...','unidentified flying objects','UFOs');

replace
-------------------------------
Some UFOs were noticed above...

该函数将查找并替换第二个参数中的每个实例,无论其出现在何处。可以使用空字符串作为第三个参数,这是删除不需要的字符串部分的好方法。与其他字符串函数一样,replace可以嵌套,其中一个replace的输出成为另一个的输入。

在我们正在处理的解析的 UFO 目击数据集中,一些location值包括指示目击发生在城市或镇“附近”、“靠近”或“外面”的修饰词。我们可以使用replace来将这些标准化为“near”:

SELECT location
,replace(replace(location,'close to','near')
         ,'outside of','near') as location_clean
FROM
(
    SELECT split_part(split_part(sighting_report,'Shape',1)
                      ,'Location: ',2) as location
    FROM ufo
) a
;

location                     location_clean
---------------------------  ---------------------
Tombstone (outside of), AZ   Tombstone (near), AZ
Terrell (close to), TX       Terrell (near), TX
Tehachapie (outside of), CA  Tehachapie (near), CA
...                          ...

最常见的 UFO 目击地点的前十名已在图 5-6 中绘制出来。

图 5-6. UFO 目击最常见的地点

现在,我们已经解析并清理了sighting_report字段的所有元素,将它们转换为了不同类型的列。最终的代码看起来像这样:

SELECT 
case when occurred = '' then null 
     when length(occurred) < 8 then null
     else occurred::timestamp 
     end as occurred
,entered_as
,case when length(reported) < 8 then null
      else reported::timestamp 
      end as reported
,case when posted = '' then null
      else posted::date  
      end as posted
,replace(replace(location,'close to','near'),'outside of','near') 
 as location
,initcap(shape) as shape
,trim(duration) as duration
FROM
(
    SELECT 
    split_part(
        split_part(split_part(sighting_report,' (Entered',1)
          ,'Occurred : ',2)
          ,'Reported',1) as occurred
    ,split_part(
      split_part(sighting_report,')',1)
        ,'Entered as : ',2) as entered_as   
    ,split_part(
      split_part(
        split_part(
          split_part(sighting_report,'Post',1)
          ,'Reported: ',2)
        ,' AM',1)
      ,' PM',1) as reported
    ,split_part(
       split_part(sighting_report,'Location',1)
       ,'Posted: ',2) as posted
    ,split_part(
       split_part(sighting_report,'Shape',1)
       ,'Location: ',2) as location
    ,split_part(
       split_part(sighting_report,'Duration',1)
       ,'Shape: ',2) as shape
    ,split_part(sighting_report,'Duration:',2) as duration
    FROM ufo
) a
;

occurred   entered_as  reported  posted   location     shape      duration
--------   ----------  --------  -------  ----------   --------   ----------
1988-...   8-8-198...  2018-...  2018...  Amity, ...   Unknown    4 minutes
2018-...   07/41/1...  2018-...  2018...  Bakersf...   Triangle   15 minutes
2018-...   08/01/1...  2018-...  2018...  Naples,...   Light      10 seconds
...        ...         ...       ...      ...          ...        ...

这段 SQL 代码可以在其他查询中重复使用,或者可以用于将原始 UFO 数据复制到新的清理过的表中。或者,它可以转换为视图或放入通用表达式以供重用。第八章将更详细地讨论这些策略。

我们已经看到如何应用解析和转换函数来清理和改善具有一定结构的文本数据的分析价值。接下来,我们将看看 UFO 目击数据集中的另一个字段,即自由文本description字段,并学习如何使用 SQL 函数来搜索特定元素。

在更大的文本块中查找元素

解析和转换是应用于文本数据的常见操作,以准备进行分析。另一个常见的操作是在更大的文本块中查找字符串。这可以用来过滤结果、分类记录或用替代值替换搜索的字符串。

通配符匹配:LIKE, ILIKE

SQL 具有几个用于在字符串中匹配模式的函数。LIKE 运算符在字符串中匹配指定的模式。为了允许它匹配模式而不仅仅是找到精确匹配,可以在模式的前面、后面或中间添加通配符符号。“%”通配符匹配零个或多个字符,“”通配符匹配正好一个字符。如果目标是匹配“%”或“”本身,请在该字符前面放置反斜杠转义符(“\”):

SELECT 'this is an example string' like '%example%';

true

SELECT 'this is an example string' like '%abc%';

false

SELECT 'this is an example string' like '%this_is%';

true

LIKE 运算符可以在 SQL 语句的多个子句中使用。它可以用于在WHERE子句中过滤记录。例如,一些报告者提到他们当时与配偶在一起,因此我们可能想知道有多少份报告提到了“wife”。由于我们希望在描述文本的任何位置找到字符串,“%”通配符将在“wife”之前和之后放置:

SELECT count(*)
FROM ufo
WHERE description like '%wife%'
;

count
-----
6231

我们可以看到,超过六千份报告提到了“wife”。但是,这只会返回小写字符串的匹配项。如果有些报告者提到了“Wife”,或者他们在键入“WIFE”时忘记了大小写锁定键呢?有两种选项可以使搜索不区分大小写。一种选项是将要搜索的字段转换为前一节中讨论的upperlower函数,这样做的效果是使搜索不区分大小写,因为字符都是大写或小写:

SELECT count(*)
FROM ufo
WHERE lower(description) like '%wife%'
;

count
-----
6439

另一种实现此目的的方法是使用 ILIKE 运算符,这实际上是一个不区分大小写的 LIKE 运算符。缺点是它不适用于每个数据库;特别是,MySQL 和 SQL Server 不支持它。但是,如果您在支持它的数据库中工作,这是一个不错的、简洁的语法选项:

SELECT count(*)
FROM ufo
WHERE description ilike '%wife%'
;

count
-----
6439

LIKE 和 ILIKE 的任何这些变体都可以通过 NOT 进行否定。因此,例如,要找到不提到“wife”的记录,我们可以使用 NOT LIKE:

SELECT count(*)
FROM ufo
WHERE lower(description) not like '%wife%'
;

count
-----
89024

可以使用 AND 和 OR 操作符对多个字符串进行过滤:

SELECT count(*)
FROM ufo
WHERE lower(description) like '%wife%'
or lower(description) like '%husband%'
;

count
-----
10571

在使用 OR 与 AND 操作符结合时,请务必使用括号来控制操作的顺序,否则可能会得到意外的结果。例如,这些WHERE子句由于 OR 在 AND 之前进行评估,所以返回的结果不相同:

SELECT count(*)
FROM ufo
WHERE lower(description) like '%wife%'
or lower(description) like '%husband%'
and lower(description) like '%mother%'
;

count
-----
6610

SELECT count(*)
FROM ufo
WHERE (lower(description) like '%wife%'
       or lower(description) like '%husband%'
       )
and lower(description) like '%mother%'
;

count
-----
382

除了在WHEREJOIN...ON子句中进行过滤之外,LIKE 还可以在SELECT子句中用于对某些记录进行分类或聚合。让我们从分类开始。LIKE 运算符可以在 CASE 语句内部使用,以标记和分组记录。一些描述中提到观察者在目击期间或之前正在进行的活动,如驾驶或步行。通过使用带有 LIKE 的 CASE 语句,我们可以找出有多少描述包含这些术语:

SELECT 
case when lower(description) like '%driving%' then 'driving'
     when lower(description) like '%walking%' then 'walking'
     when lower(description) like '%running%' then 'running'
     when lower(description) like '%cycling%' then 'cycling'
     when lower(description) like '%swimming%' then 'swimming'
     else 'none' end as activity
,count(*)
FROM ufo
GROUP BY 1
ORDER BY 2 desc
;

activity  count
--------  -----
none      77728
driving   11675
walking   4516
running   1306
swimming  196
cycling   42

最常见的活动是驾驶,而不是很多人在游泳或骑行时报告目击。这或许并不令人惊讶,因为这些活动相对于驾驶来说较少见。

小贴士

尽管通过文本解析转换函数得到的值可以用于JOIN条件,但数据库性能通常是一个问题。考虑在子查询中进行解析和/或转换,然后将结果与JOIN子句中的精确匹配进行连接。

请注意,此 CASE 语句仅为每个描述标记一个活动,并评估每条记录是否与语句中写入的模式匹配。包含“驾驶”和“步行”等内容的描述将被标记为“驾驶”。在许多情况下,这是合适的,特别是在分析较长的文本(如评论、调查评论或支持票证)时,标记记录具有多个类别的能力至关重要。对于这种用例,需要一系列二进制或布尔标志列。

我们之前看到,LIKE 可以用于生成 TRUE 或 FALSE 的布尔响应,并且我们可以使用它来标记行。在数据集中,许多描述提到了检测到对象的方向,如北或南,有些描述提到了多个方向。我们可能希望为每条记录添加一个字段,指示描述中是否提到了每个方向:

SELECT description ilike '%south%' as south
,description ilike '%north%' as north
,description ilike '%east%' as east
,description ilike '%west%' as west
,count(*)
FROM ufo
GROUP BY 1,2,3,4
ORDER BY 1,2,3,4
;
south  north  east   west   count
-----  -----  ----   -----  -----
false  false  false  false  43757
false  false  false  true   3963
false  false  true   false  5724
false  false  true   true   4202
false  true   false  false  4048
false  true   false  true   2607
false  true   true   false  3299
false  true   true   true   2592
true   false  false  false  3687
true   false  false  true   2571
true   false  true   false  3041
true   false  true   true   2491
true   true   false  false  3440
true   true   false  true   2064
true   true   true   false  2684
true   true   true   true   5293

结果是一个布尔矩阵,可以用来查找各种方向组合的频率,或者查找在同一描述中使用某个方向而不使用其他方向的情况。

所有这些组合在某些情境中都很有用,特别是在构建将由其他人用于探索数据或在 BI 或可视化工具中使用的数据集时。然而,有时更有用的是进一步总结数据并对包含字符串模式的记录执行聚合。在这里,我们将计算记录的数量,但如果数据集包含其他数值字段(如销售数据),也可以使用诸如sumaverage之类的其他聚合函数:

SELECT 
count(case when description ilike '%south%' then 1 end) as south
,count(case when description ilike '%north%' then 1 end) as north
,count(case when description ilike '%west%' then 1 end) as west
,count(case when description ilike '%east%' then 1 end) as east
FROM ufo
;

south  north  west   east
-----  -----  -----  -----
25271  26027  25783  29326

现在我们对描述字段中方向术语的频率有了一个更紧凑的总结,可以看到“东”比其他方向提到得更频繁。结果在图 5-7 中绘制出来。

图 5-7. UFO 目击报告中罗盘方向频率

在前面的查询中,我们仍然允许包含多个方向的记录被计数多次。然而,现在无法看到具体的组合情况。可以根据需要在查询中添加复杂性,以处理此类情况,例如:

count(case when description ilike '%east%' 
and description ilike '%north%' then 1 end) as east

使用 LIKE、NOT LIKE 和 ILIKE 进行模式匹配是灵活的,可以在 SQL 查询的各种地方使用,以过滤、分类和聚合数据,以满足各种输出需求。这些操作符可以与我们之前讨论的文本解析和转换函数结合使用,提供更多的灵活性。接下来,我将讨论在匹配完全匹配时处理多个元素,然后返回讨论正则表达式中的更多模式。

精确匹配:IN、NOT IN

在我们继续讨论使用正则表达式进行更复杂的模式匹配之前,值得看看一些在文本分析中有用的额外操作符。虽然这些操作符不完全是关于模式匹配的,但它们通常与 LIKE 及其相关操作符一起使用,以制定一个包含准确结果集的规则集。操作符包括 IN 及其否定形式 NOT IN。这些操作符允许你指定一个匹配列表,从而使代码更加紧凑。

让我们假设我们有兴趣根据description的第一个单词来对目击事件进行分类。我们可以使用split_part函数,空格作为分隔符,找到第一个单词。许多报告以颜色作为第一个单词开始。我们可能想筛选记录,以查看以命名颜色开始的报告。这可以通过列出每种颜色并使用 OR 构造来完成:

SELECT first_word, description
FROM
(
    SELECT split_part(description,' ',1) as first_word
    ,description
    FROM ufo
) a
WHERE first_word = 'Red'
or first_word = 'Orange'
or first_word = 'Yellow'
or first_word = 'Green'
or first_word = 'Blue'
or first_word = 'Purple'
or first_word = 'White'
;

first_word  description
----------  ----------------------------------------------------
Blue        Blue Floating LightSaw blue light hovering...
White       White dot of light traveled across the sky, very...
Blue        Blue Beam project known seen from the high desert... 
...         ...

使用 IN 列表更为紧凑,并且通常更不易出错,特别是在WHERE子句中有其他元素时。IN 接受一个由逗号分隔的项列表进行匹配。元素的数据类型应与列的数据类型匹配。如果数据类型为数字,元素应为数字;如果数据类型为文本,元素应作为文本加引号(即使元素是数字):

SELECT first_word, description
FROM
(
    SELECT split_part(description,' ',1) as first_word
    ,description
    FROM ufo
) a
WHERE first_word in ('Red','Orange','Yellow','Green','Blue','Purple','White')
;

first_word  description
----------  ----------------------------------------------------
Red         Red sphere with yellow light in middleMy Grandson... 
Blue        Blue light fireball shape shifted into several...
Orange      Orange lights.Strange orange-yellow hovering not...
...         ...

两 这两种形式在结果上是相同的,频率显示在图 5-8 中。

图 5-8。UFO 目击描述中选择颜色作为第一个单词的频率

IN 和 NOT IN 的主要好处是它们使代码更加紧凑和易读。当在SELECT子句中创建更复杂的分类时,这非常有用。例如,假设我们想按第一个单词将记录分类和计数为颜色、形状、移动或其他可能的单词。我们可能会想出类似以下内容的组合,结合了解析、转换、模式匹配和 IN 列表的元素:

SELECT 
case when lower(first_word) in ('red','orange','yellow','green', 
'blue','purple','white') then 'Color'
when lower(first_word) in ('round','circular','oval','cigar') 
then 'Shape'
when first_word ilike 'triang%' then 'Shape'
when first_word ilike 'flash%' then 'Motion'
when first_word ilike 'hover%' then 'Motion'
when first_word ilike 'pulsat%' then 'Motion'
else 'Other' 
end as first_word_type
,count(*)
FROM
(
    SELECT split_part(description,' ',1) as first_word
    ,description
    FROM ufo
) a
GROUP BY 1
ORDER BY 2 desc
;

first_word_type  count
---------------  -----
Other            85268
Color            6196
Shape            2951
Motion           1048

当然,考虑到这个数据集的特性,可能需要更多的代码行和规则来准确分类报告的第一个单词。SQL 允许你创建各种复杂和微妙的表达式来处理文本数据。接下来,我们将探讨在 SQL 中处理文本数据更加复杂的方法,使用正则表达式。

正则表达式

在 SQL 中有许多匹配模式的方法。其中一种最强大的方法,尽管也很令人困惑,是使用正则表达式(regex)。我承认,我对正则表达式感到有些害怕,而且在我的数据分析职业生涯中很长一段时间内避免使用它们。在紧急情况下,我很幸运有同事愿意分享代码片段,并帮助我解决工作中遇到的问题。直到我接手了一个大型文本分析项目,我才决定是时候学习它们了。

正则表达式是由许多具有特殊含义的字符序列组成,用于定义搜索模式。学习正则表达式的主要挑战,以及在使用和维护包含它的代码时,其语法并不特别直观。代码片段读起来不像人类语言,甚至不像 SQL 或 Python 等计算机语言。然而,只要掌握了特殊字符的工作原理,就可以编写和解密代码。与我们所有查询的代码一样,从简单开始,根据需要增加复杂性,并在进行过程中检查结果是个好主意。同时,为其他分析员和未来的你留下大量注释也很重要。

正则表达式是一种语言,但仅在其他语言中使用。例如,正则表达式可以在 Java、Python 和 SQL 中调用,但没有独立的编程方式。所有主要的数据库都有某种形式的正则表达式实现。尽管语法不总是完全相同,但与其他函数一样,一旦你了解了可能性,调整语法以适应你的环境就是可能的。

完整的解释以及正则表达式的所有语法和用法超出了本书的范围,但我将展示足够的内容让你开始并完成一些 SQL 中的常见任务。如果你需要更详细的介绍,可以选择 Ben Forta(O’Reilly)的学习正则表达式。首先,我将介绍如何向数据库指示你正在使用正则表达式,然后介绍语法,并且示例说明正则表达式在 UFO 目击报告分析中的实际用途。

在 SQL 语句中可以用正则表达式有两种方式。第一种是使用 POSIX 比较器,第二种是使用正则表达式函数。POSIX 代表可移植操作系统接口,是一组 IEEE 标准,但你只需知道这些即可在 SQL 代码中使用 POSIX 比较器。第一个比较器是 ~(波浪线)符号,用于比较两个语句,如果一个字符串包含在另一个字符串中则返回 TRUE。举个简单例子,我们可以检查字符串 “The data is about UFOs” 是否包含字符串 “data”:

SELECT 'The data is about UFOs' ~ 'data' as comparison;

comparison
----------
true

返回值是一个 BOOLEAN,TRUE 或 FALSE。注意,尽管它不包含任何特殊语法,“data” 是一个正则表达式。正则表达式也可以包含普通文本字符串。这个例子类似于使用 LIKE 运算符完成的功能。~ 比较器是区分大小写的。要使它不区分大小写,类似于 ILIKE,使用 ~*(波浪线后跟一个星号):

SELECT 'The data is about UFOs' ~* 'DATA' as comparison;

comparison
----------
true

要对比较器取反,可以在波浪线或波浪线后跟星号的组合前加一个 !(感叹号):

SELECT 'The data is about UFOs' !~ 'alligators' as comparison;

comparison
----------
true

表 5-1 总结了四个 POSIX 比较器。

表 5-1. POSIX 比较器

语法 功能 区分大小写?
~ 比较两个语句,如果一个包含在另一个中则返回 TRUE
~* 比较两个语句,如果一个包含在另一个中则返回 TRUE
!~ 比较两个语句,如果一个包含在另一个中则返回 FALSE
!~* 比较两个语句,如果一个包含在另一个中则返回 FALSE

现在我们有了一种方法在 SQL 中引入正则表达式,让我们熟悉一些它提供的特殊模式匹配语法。要知道的第一个特殊字符是 .(句点)符号,它是用于匹配任何单个字符的通配符:

SELECT 
'The data is about UFOs' ~ '. data' as comparison_1
,'The data is about UFOs' ~ '.The' as comparison_2
;

comparison_1  comparison_2
------------  ------------
true          false

让我们分解这个过程,以便理解正在发生的事情,并培养我们对正则表达式如何工作的直觉。在第一个比较中,模式尝试匹配任何字符(由句点表示),然后是一个空格,然后是单词 “data”。这个模式在示例句子中匹配字符串 “e data”,因此返回 TRUE。如果这看起来反直觉,因为在字母 “e” 前面和 “data” 后面有额外的字符,记住比较器只是在字符串中某处寻找这个模式,类似于 LIKE 运算符。在第二个比较中,模式尝试匹配任何字符后跟 “The”。由于在示例句子中 “The” 是字符串的开头,并且它之前没有字符,因此返回 FALSE。

要匹配多个字符,请使用 (星号)符号。这将匹配零个或多个字符,类似于在 LIKE 语句中使用%(百分号)符号。 这种星号的用法不同于将其立即放在波浪号(~)之后,后者使匹配不区分大小写。但是请注意,在这种情况下,“%”不是通配符,而是要匹配的文字字符:

SELECT 'The data is about UFOs' ~ 'data *' as comparison_1
,'The data is about UFOs' ~ 'data %' as comparison_2
;

comparison_1  comparison_2
------------  ------------
true          false

下一个需要了解的特殊字符是 [ 和 ](左方括号和右方括号)。它们用于括住一组字符,其中任何一个必须匹配。尽管方括号之间可以有多个字符,但它们匹配单个字符,不过我们很快将看到如何多次匹配。方括号的一个用途是通过在方括号内将大写和小写字母括起来使模式部分不区分大小写(不要使用逗号,因为那会匹配逗号字符本身):

SELECT 'The data is about UFOs' ~ '[Tt]he' as comparison;

comparison
----------
true

在此示例中,该模式将匹配“the”或“The”;由于此字符串是例句的开头,语句返回 TRUE 值。 这与不区分大小写匹配~*并不完全相同,因为在此案例中,“tHe”和“THE”等变体不匹配该模式:

SELECT 'The data is about UFOs' ~ '[Tt]he' as comparison_1
,'the data is about UFOs' ~ '[Tt]he' as comparison_2
,'tHe data is about UFOs' ~ '[Tt]he' as comparison_3
,'THE data is about UFOs' ~ '[Tt]he' as comparison_4
;

comparison_1  comparison_2  comparison_3  comparison_4
------------  ------------  ------------  ------------
true          true          false         false

方括号集合匹配的另一种用途是匹配包含数字的模式,允许任何数字。例如,想象一下我们想匹配任何提到“7 分钟”,“8 分钟”或“9 分钟”的描述。这可以通过使用带有几个 LIKE 操作符的 CASE 语句来实现,但是使用正则表达式的模式语法更加紧凑:

SELECT 'sighting lasted 8 minutes' ~ '[789] minutes' as comparison;

comparison
----------
true

要匹配任何数字,我们可以在方括号之间包含所有数字:

[0123456789]

然而,正则表达式允许使用 -(短划线)分隔符输入一系列字符。 所有数字可以用[0-9]表示。 也可以使用更小的数字范围,例如[0-3]或[4-9]。 此模式,与范围一起使用,相当于最后一个列出每个数字的示例:

SELECT 'sighting lasted 8 minutes' ~ '[7-9] minutes' as comparison;

comparison
----------
true

字母范围可以以类似的方式匹配。 表 5-2 总结了在 SQL 分析中最有用的范围模式。非数字和非字母的值也可以放在方括号之间,例如 [$%@]。

表 5-2. 正则表达式范围模式

范围模式 目的
[0-9] 匹配任何数字
[a-z] 匹配任何小写字母
[A-Z] 匹配任何大写字母
[A-Za-z0-9] 匹配任何小写字母或大写字母,或任何数字
[A-z] 匹配任何 ASCII 字符;通常不使用,因为它匹配所有内容,包括符号

如果所需的模式匹配包含特定值或类型的值的多个实例,则一种选项是包括所需数量的范围,依次排列。例如,我们可以通过多次重复数字范围符号来匹配三位数:

SELECT 'driving on 495 south' ~ 'on [0-9][0-9][0-9]' as comparison;

comparison
----------
true

另一种选择是使用可选的特殊语法之一来多次重复模式。当你不确定模式将重复多少次时,这可能很有用,但要小心检查结果,确保不要意外返回比预期更多的匹配项。要匹配一次或多次,请在模式后面加上+(加号)符号:

SELECT 
'driving on 495 south' ~ 'on [0-9]+' as comparison_1
,'driving on 1 south' ~ 'on [0-9]+' as comparison_2
,'driving on 38east' ~ 'on [0-9]+' as comparison_3
,'driving on route one' ~ 'on [0-9]+' as comparison_4
;

comparison_1  comparison_2  comparison_3  comparison_4
------------  ------------  ------------  ------------
true          true          true          false

表 5-3 总结了指示重复模式次数的其他选项。

表 5-3. 用于多次匹配字符集的正则表达式模式;在每种情况下,符号或符号紧跟在集合表达式之后

符号 目的
+ 匹配字符集一次或多次
* 匹配字符集零次或多次
? 匹配字符集零次或一次
匹配指定次数的字符集;例如,{3} 精确匹配三次
在花括号之间指定的逗号分隔数字范围内匹配字符集的任意次数;例如,{3,5} 匹配三到五次

有时,我们不想匹配一个模式,而是想找到不匹配模式的项。可以在模式前面放置^(插入符)符号来执行此操作,该符号用于否定模式:

SELECT 
'driving on 495 south' ~ 'on [0-9]+' as comparison_1
,'driving on 495 south' ~ 'on ^[0-9]+' as comparison_2
,'driving on 495 south' ~ '^on [0-9]+' as comparison_3
;

comparison_1  comparison_2  comparison_3
------------  ------------  ------------ 
true          false         false

我们可能想要匹配包含特殊字符之一的模式,因此我们需要一种方法告诉数据库检查该文字字符而不将其视为特殊字符。为此,我们需要一个转义字符,在正则表达式中是反斜杠(\)符号:

SELECT 
'"Is there a report?" she asked' ~ '\?' as comparison_1
,'it was filed under ⁵¹.' ~ '^[0-9]+' as comparison_2
,'it was filed under ⁵¹.' ~ '\^[0-9]+' as comparison_3
;

comparison_1  comparison_2  comparison_3
------------  ------------  ------------ 
true          false         true

在第一行中,省略问号前面的反斜杠会导致数据库返回“无效的正则表达式”错误(错误的确切措辞可能因数据库类型而异)。在第二行中,即使后面跟着一个或多个数字([0-9]+),数据库也会将解释为否定,并评估字符串是否不包含指定的数字。第三行使用反斜杠转义插入符号,现在数据库将其解释为字面插入符号。

文本数据通常包括空白字符。这些字符从我们眼睛注意到的空格开始,到微妙且有时未打印的制表符和换行符。稍后我们将看到如何使用正则表达式替换这些字符,但现在让我们关注如何在正则表达式中匹配它们。制表符用\t 匹配。换行符用\r(回车)或\n(换行符)匹配,根据操作系统的不同,有时需要同时使用:\r\n。通过运行几个简单的查询来尝试您的环境,看看返回什么结果可以达到期望的效果。要匹配任何空白字符,请使用\s,但请注意这也会匹配空格字符:

SELECT 
'spinning
flashing
and whirling' ~ '\n' as comparison_1
,'spinning
flashing
and whirling' ~ '\s' as comparison_2
,'spinning flashing' ~ '\s' as comparison_3
,'spinning' ~ '\s' as comparison_4
;  

comparison_1  comparison_2  comparison_3  comparison_4
------------  ------------  ------------  ------------
true          true          true          false
提示

SQL 查询工具或 SQL 查询解析器可能难以解释直接键入它们的新行,因此可能会返回错误。如果是这种情况,请尝试从源中复制并粘贴文本,而不是直接键入。所有 SQL 查询工具应能够处理存在于数据库表中的换行。

与数学表达式类似,括号可以用于包含应一起处理的表达式。例如,我们可能希望匹配一个相对复杂的模式,该模式重复多次:

SELECT 
'valid codes have the form 12a34b56c' ~ '([0-9]{2}[a-z]){3}' 
  as comparison_1
,'the first code entered was 123a456c' ~ '([0-9]{2}[a-z]){3}' 
  as comparison_2
,'the second code entered was 99x66y33z' ~ '([0-9]{2}[a-z]){3}' 
  as comparison_3
;

comparison_1  comparison_2  comparison_3
------------  ------------  ------------
true          false          true

所有三行使用相同的正则表达式模式,'([0-9]{2}[a-z]){3}',用于匹配。括号内的模式 [0-9]{2}[a-z] 寻找两位数字后跟一个小写字母。括号外的 {3} 表示整个模式应重复三次。第一行符合此模式,因为它包含字符串 12a34b56c。第二行不匹配该模式;它确实有两位数字后跟一个小写字母 (23a),然后又有两位数字 (23a45),但这第二次重复后面跟着第三位数字而不是另一个小写字母 (23a456),因此没有匹配。第三行具有匹配模式 99x66y33z

正如我们刚刚看到的,正则表达式可以与其他表达式(包括正则表达式和普通文本)以任意组合使用,以创建模式匹配代码。除了指定 匹配的内容外,正则表达式还可用于指定 匹配 的位置。使用特殊字符 \y 可以在单词的开头或结尾匹配模式(在某些数据库中,可能是 \b)。举个例子,想象一下我们有兴趣在 UFO 目击报告中找到单词“car”。我们可以写出这样的表达式:

SELECT 
'I was in my car going south toward my home' ~ 'car' as comparison;

comparison
----------
true

它在字符串中找到“car”并返回预期的 TRUE。然而,让我们从数据集中再看几个字符串,寻找相同的表达式:

SELECT 
'I was in my car going south toward my home' ~ 'car' 
  as comparison_1
,'UFO scares cows and starts stampede breaking' ~ 'car' 
  as comparison_2
,'I''m a carpenter and married father of 2.5 kids' ~ 'car' 
  as comparison_3
,'It looked like a brown boxcar way up into the sky' ~ 'car' 
  as comparison_4
;

comparison_1  comparison_2  comparison_3  comparison_4
------------  ------------  ------------  ------------
true          true          true          true

所有这些字符串也会匹配模式“car”,尽管“scares”、“carpenter”和“boxcar”并不完全符合我们寻找车辆提及时的意图。为了修正这个问题,我们可以在表达式中“car”模式的开头和结尾添加 \y:

SELECT 
'I was in my car going south toward my home' ~ '\ycar\y' 
  as comparison_1
,'UFO scares cows and starts stampede breaking' ~ '\ycar\y' 
  as comparison_2
,'I''m a carpenter and married father of 2.5 kids' ~ '\ycar\y' 
  as comparison_3
,'It looked like a brown boxcar way up into the sky' ~ '\ycar\y' 
  as comparison_4
;

comparison_1  comparison_2  comparison_3  comparison_4
------------  ------------  ------------  ------------
true          false         false         false

在这个简单的例子中,我们当然可以在单词“car”前后加上空格,结果也是一样的。这种模式的好处在于,它还会匹配那些模式位于字符串开头的情况,因此没有前导空格:

SELECT 'Car lights in the sky passing over the highway' ~* '\ycar\y' 
 as comparison_1
,'Car lights in the sky passing over the highway' ~* ' car ' 
 as comparison_2
;

comparison_1  comparison_2
------------  ------------
true          false

模式 '\ycar\y' 在“Car”是第一个单词时进行不区分大小写的匹配,但模式 ' car ' 则不会。要匹配整个字符串的开头,请使用特殊字符 \A,要匹配字符串的结尾,请使用 \Z:

SELECT 
'Car lights in the sky passing over the highway' ~* '\Acar\y' 
  as comparison_1
,'I was in my car going south toward my home' ~* '\Acar\y' 
  as comparison_2
,'An object is sighted hovering in place over my car' ~* '\ycar\Z' 
  as comparison_3
,'I was in my car going south toward my home' ~* '\ycar\Z' 
  as comparison_4
;

comparison_1  comparison_2  comparison_3  comparison_4
------------  ------------  ------------  ------------
true          false         true          false

在第一行中,模式在字符串开头匹配“Car”。第二行以“I”开头,因此模式不匹配。在第三行中,模式在字符串末尾寻找“car”并匹配成功。最后,在第四行中,最后一个单词是“home”,因此模式不匹配。

如果这是您第一次使用正则表达式,可能需要通过 SQL 编辑器多次阅读并进行一些实验才能掌握它们的使用方法。没有什么比实际示例更有助于巩固学习,接下来我将介绍一些应用于 UFO 目击分析的示例,并介绍一些特定的 regex SQL 函数。

注意

不同数据库供应商的正则表达式实现差异很大。本节中的 POSIX 运算符适用于 Postgres 及其衍生的数据库,如 Amazon Redshift,但不一定适用于其他数据库。

与 ~ 运算符的另一种选择是rlikeregexp_like函数(取决于数据库)。它们的格式如下:

regexp_like(*string*, *pattern*, *optional_parameters*)

本节中的第一个示例将写成:

SELECT regexp_like('The data is about UFOs','data') 
 as comparison;

可选参数控制匹配类型,例如匹配是否不区分大小写。

这些数据库中有许多其他未在此处涵盖的额外函数,例如regexp_substr用于查找匹配的子字符串,以及regexp_count用于计算模式匹配的次数。Postgres 支持 POSIX,但遗憾的是不支持这些其他函数。希望进行大量文本分析的组织最好选择一个具有强大正则表达式函数集的数据库类型。

使用 regex 进行查找和替换

在前一节中,我们讨论了正则表达式以及如何使用 regex 构造模式来匹配数据集中字符串的部分。让我们将这一技术应用到 UFO 目击数据集中,看看它在实践中的效果。在此过程中,我还将介绍一些额外的 regex SQL 函数。

目击报告包含各种细节,例如目击者在目击时正在做什么,何时何地进行目击。另一个常被提及的细节是看到一些数量的光。作为第一个示例,让我们找出包含数字和单词“light”或“lights”的描述。为了在本书中展示,我将只检查前 100 个字符,但此代码也可以在整个描述字段中运行:

SELECT left(description,50)
FROM ufo
WHERE left(description,50) ~ '[0-9]+ light[s ,.]'
;

left
--------------------------------------------------
Was walking outside saw 5 lights in a line changed
2 lights about 5 mins apart, goin from west to eas
Black triangular aircraft with 3 lights hovering a
...

正则表达式模式匹配任意数量的数字([0-9]+),然后是一个空格,然后是字符串“light”,最后是字母“s”,空格,逗号或句号之一。除了找到相关记录外,我们可能还想分离出仅涉及数字和单词“lights”的部分。为此,我们将使用 regex 函数regexp_matches

提示

正则表达式函数的支持因数据库供应商而异,有时也因数据库软件版本而异。SQL Server 不支持这些函数,而 MySQL 对它们的支持很有限。Redshift、Snowflake 和 Vertica 等分析型数据库支持各种有用的函数。Postgres 只支持匹配和替换函数。请查阅您的数据库文档以了解特定函数的可用性。

regexp_matches函数接受两个参数:要搜索的字符串和正则表达式匹配模式。它返回一个匹配模式的字符串数组。如果没有匹配项,则返回空值。由于返回值是一个数组,我们将使用索引[1]只返回一个 VARCHAR 值,这将允许根据需要进行其他字符串操作。如果您在其他类型的数据库中工作,regexp_substr函数类似于regexp_matches,但它返回一个 VARCHAR 值,因此不需要添加[1]索引。

提示

数组是存储在计算机内存中的对象集合。在数据库中,数组被包含在{ }(大括号)中,这是一种识别数据库中不是我们迄今为止一直在处理的常规数据类型之一的好方法。数组在存储和检索数据时具有一些优势,但在 SQL 中使用起来不那么简单,因为它们需要特殊的语法。数组中的元素使用[ ](方括号)表示法访问。对于我们的目的,知道第一个元素可以用[1]找到,第二个可以用[2],以此类推,已经足够了。

基于我们的例子,我们可以从描述字段中解析所需的值,即数字和“light(s)”一词,然后按此值和最常见的变体GROUP BY

SELECT (regexp_matches(description,'[0-9]+ light[s ,.]'))[1]
,count(*)
FROM ufo
WHERE description ~ '[0-9]+ light[s ,.]'
GROUP BY 1
ORDER BY 2 desc
; 

regexp_matches  count
--------------  -----
3 lights        1263
2 lights        565
4 lights        549
...             ...

前 10 个结果在图 5-9 中绘制。

图 5-9. UFO 目击描述开头提到的灯数

提到三个灯的报告比第二经常提到的灯数多两倍以上,从两到六盏灯最常见。要找到灯数的完整范围,我们可以解析匹配的文本,然后找到minmax值:

SELECT min(split_part(matched_text,' ',1)::int) as min_lights
,max(split_part(matched_text,' ',1)::int) as max_lights
FROM
(
    SELECT (regexp_matches(description
                           ,'[0-9]+ light[s ,.]')
                           )[1] as matched_text
    ,count(*)
    FROM ufo
    WHERE description ~ '[0-9]+ light[s ,.]'
    GROUP BY 1
) a
; 

min_lights  max_lights
----------  -----
0           2000

至少有一份报告提到了两千盏灯,而零灯的最小值也被提到。我们可能需要进一步审查这些报告,看看这些极端值是否还有其他有趣或不寻常的内容。

除了找到匹配项之外,我们可能希望用一些替代文本替换匹配的文本。当尝试清理数据集中具有同一基础内容多种拼写的文本时,这尤为有用。regexp_replace函数可以实现这一点。它类似于本章前面讨论过的replace函数,但它可以接受正则表达式作为匹配模式的参数。语法与replace函数类似:

regexp_replace(*field or string*, *pattern*, *replacement value*)

让我们将其应用于之前从sighting_report列中解析出来的duration字段的清理工作。这似乎是一个自由文本输入字段,有超过八千个不同的值。但是,检查后发现有共同的主题——大多数涉及秒、分钟和小时的某种组合:

SELECT split_part(sighting_report,'Duration:',2) as duration
,count(*) as reports
FROM ufo
GROUP BY 1
;

duration    reports
--------    -------
10 minutes  4571
1 hour      1599
10 min      333
10 mins     150
>1 hour     113
...         ...

在这个示例中,“10 minutes”,“10 min”和“10 mins”的持续时间都表示相同的时间量,但是数据库并不知道如何将它们合并,因为拼写略有不同。我们可以使用一系列嵌套的replace函数来转换所有这些不同的拼写。但是,我们还必须考虑到其他变体,例如大小写。在这种情况下,正则表达式非常方便,允许我们创建更紧凑的代码。第一步是开发一个匹配所需字符串的模式,我们可以使用regexp_matches函数来实现。审查这个中间步骤以确保我们匹配到了正确的文本是个好主意:

SELECT duration
,(regexp_matches(duration
                 ,'\m[Mm][Ii][Nn][A-Za-z]*\y')
                 )[1] as matched_minutes
FROM
(
    SELECT split_part(sighting_report,'Duration:',2) as duration
    ,count(*) as reports
    FROM ufo
    GROUP BY 1
) a
;

duration      matched_minutes
------------  ---------------
10 min.       min
10 minutes+   minutes
10 min        min
10 minutes +  minutes
10 minutes?   minutes
10 minutes    minutes
10 mins       mins
...           ...

让我们来分解一下。在子查询中,duration值从sighting_report字段中分离出来。然后,regexp_matches函数查找与模式匹配的字符串:

'\m[Mm][Ii][Nn][A-Za-z]*\y'

这个模式从单词的开头(\m)开始,并查找“m”,“i”和“n”这些字母的任意序列,不区分大小写([Mm]等)。接下来,它查找零个或多个其他小写或大写字母([A-Za-z]*),最后检查单词的结尾(\y),以便只包括包含“minutes”变体的单词,而不是字符串的其余部分。请注意,“+”和“?”字符不匹配。有了这个模式,我们现在可以用标准值“min”替换所有这些变体:

SELECT duration
,(regexp_matches(duration
                 ,'\m[Mm][Ii][Nn][A-Za-z]*\y')
                 )[1] as matched_minutes
,regexp_replace(duration
                 ,'\m[Mm][Ii][Nn][A-Za-z]*\y'
                 ,'min') as replaced_text
FROM
(
    SELECT split_part(sighting_report,'Duration:',2) as duration
    ,count(*) as reports
    FROM ufo
    GROUP BY 1
) a
;

duration      matched_minutes  replaced_text
-----------   ---------------  -------------
10 min.       min              10 min.
10 minutes+   minutes          10 min+
10 min        min              10 min
10 minutes +  minutes          10 min +
10 minutes?   minutes          10 min?
10 minutes    minutes          10 min
10 mins       mins             10 min
...           ...              ...

replaced_text列中的值现在更加标准化了。句号、加号和问号字符也可以通过增强正则表达式进行替换。然而,从分析的角度来看,我们可能需要考虑如何表示加号和问号所代表的不确定性。regexp_replace函数可以嵌套使用,以实现对不同部分或类型字符串的替换。例如,我们可以标准化分钟和小时:

SELECT duration
,(regexp_matches(duration
                 ,'\m[Hh][Oo][Uu][Rr][A-Za-z]*\y')
                 )[1] as matched_hour
,(regexp_matches(duration
                 ,'\m[Mm][Ii][Nn][A-Za-z]*\y')
                 )[1] as matched_minutes
,regexp_replace(
        regexp_replace(duration
                       ,'\m[Mm][Ii][Nn][A-Za-z]*\y'
                       ,'min') 
        ,'\m[Hh][Oo][Uu][Rr][A-Za-z]*\y'
        ,'hr') as replaced_text
FROM
(
    SELECT split_part(sighting_report,'Duration:',2) as duration
    ,count(*) as reports
    FROM ufo
    GROUP BY 1
) a
;

duration             matched_hour  matched_minutes  replaced_text
-------------------  ------------  ---------------  -------------
1 Hour 15 min        Hour          min              1 hr 15 min
1 hour & 41 minutes  hour          minutes          1 hr & 41 min
1 hour 10 mins       hour          mins             1 hr 10 min
1 hour 10 minutes    hour          minutes          1 hr 10 min
...                  ...           ...              ...

小时的正则表达式与分钟的相似,查找在单词开头的不区分大小写的“hour”,后跟零个或多个其他字母字符,直到单词的结尾。在最终结果中可能不需要中间的小时和分钟匹配,但我发现它们在开发 SQL 代码时有助于检查,以防后续出现错误。对duration列的完整清理可能需要更多行代码,很容易迷失并引入拼写错误。

regexp_replace 函数可以嵌套任意次数,或者可以与基本的 replace 函数结合使用。regexp_replace 的另一个用途是在 CASE 语句中,用于在满足语句中条件时进行有针对性的替换。正则表达式是 SQL 中强大且灵活的工具,正如我们所见,它可以在整体 SQL 查询中以多种方式使用。

在本节中,我介绍了许多搜索、查找和替换长文本中特定元素的方法,从通配符匹配(LIKE)到 IN 列表,再到使用正则表达式进行更复杂的模式匹配。所有这些方法,连同之前介绍的文本解析和转换函数,使我们能够创建具有处理当前数据集所需复杂性的定制规则集。然而,值得注意的是,在一次性数据集分析中,创建完美清理数据的复杂规则集可能值得一试。对于持续报告和监控,通常值得探索从数据源获得更清洁数据的选项。接下来,我们将讨论几种使用 SQL 构建新文本字符串的方法:使用常量、现有字符串和解析字符串。

构建和重塑文本

我们已经看到如何解析、转换、查找和替换字符串的元素,以执行各种清理和分析任务。除了这些功能之外,SQL 还可以用于生成文本的新组合。在本节中,我将首先讨论 连接,它允许不同字段和数据类型被合并为单个字段。然后我将讨论使用函数将多列合并为单行的文本形状变化,以及相反的操作:将单个字符串拆分为多行。

连接

可以使用 SQL 进行文本的连接来创建新文本。可以将常量或硬编码文本、数据库字段及这些字段上的计算结合在一起。有几种连接的方法。大多数数据库支持 concat 函数,该函数将字段或值作为参数进行连接:

concat(*value1*, *value2*)
concat(*value1*, *value2*, *value3*...)

一些数据库支持 concat_ws(带分隔符的连接)函数,该函数以分隔符值作为第一个参数,后跟要连接的值列表。当有多个值需要使用逗号、破折号或类似元素分隔时,这非常有用:

concat_ws(*separator*, *value1*, *value2*...)

最后,||(双竖线)可以在许多数据库中用于连接字符串(SQL Server 使用 + 代替):

value1 || value2
小贴士

如果连接中的任何值为空,数据库将返回空。如果怀疑可能出现空值,请务必使用 coalesce 或 CASE 替换空值为默认值。

连接可以将字段和常量字符串组合在一起。例如,想象一下,我们想要将形状标记为这样,并在每个形状的报告计数中添加“报告”一词。子查询从 sighting_report 字段中解析形状的名称并计算记录数。外部查询使用字符串 ' (shape)'' reports' 连接形状和报告:

SELECT concat(shape, ' (shape)') as shape
,concat(reports, ' reports') as reports
FROM
(
    SELECT split_part(
                 split_part(sighting_report,'Duration',1)
                 ,'Shape: ',2) as shape
    ,count(*) as reports
    FROM ufo
    GROUP BY 1
) a
;

Shape             reports
----------------  ------------
Changing (shape)  2295 reports
Chevron (shape)   1021 reports
Cigar (shape)     2119 reports
...               ...

我们也可以将两个字段结合在一起,可选地使用字符串分隔符。例如,我们可以将形状和位置的值合并到一个字段中:

SELECT concat(shape,' - ',location) as shape_location
,reports
FROM
(
    SELECT 
    split_part(split_part(sighting_report,'Shape',1)
      ,'Location: ',2) as location
    ,split_part(split_part(sighting_report,'Duration',1)
       ,'Shape: ',2) as shape
    ,count(*) as reports
    FROM ufo
    GROUP BY 1,2
) a
;

shape_location           reports
-----------------------  -------
Light - Albuquerque, NM  58
Circle - Albany, OR      11
Fireball - Akron, OH     8
...                      ...

前 10 个组合在 图 5-10 中绘制出来。

图 5-10. UFO 目击中形状和位置的顶级组合

我们之前看到,“光”是最常见的形状,因此它出现在每个顶级结果中并不奇怪。Phoenix 是最常见的位置,而拉斯维加斯则是整体第二常见的。

在这种情况下,由于我们费了那么大的劲来解析不同的字段,将它们再次连接在一起可能就没有那么有意义了。但是,将文本重新排列或将值组合到单个字段中以便在其他工具中显示是很有用的。通过组合各种字段和文本,我们还可以生成作为数据摘要的句子,用于电子邮件或自动报告。在这个例子中,子查询 a 解析了 occurredshape 字段,正如我们之前所看到的,然后 count 记录。然后在子查询 aa 中,计算了 occurredminmax,以及 reports 的总数,并按 shape 进行了 GROUP。排除了 occurred 字段长度少于八个字符的行,以删除没有正确格式化日期并避免在 minmax 计算中出现错误的行。最后,在外部查询中,使用 concat 函数组装最终的文本。日期的格式被更改为长日期形式(1957 年 4 月 9 日),显示最早和最近的日期:

SELECT 
concat('There were '
       ,reports
       ,' reports of '
       ,lower(shape)
       ,' objects. The earliest sighting was '
       ,trim(to_char(earliest,'Month'))
       , ' '
       , date_part('day',earliest)
       , ', '
       , date_part('year',earliest)
       ,' and the most recent was '
       ,trim(to_char(latest,'Month'))
       , ' '
       , date_part('day',latest)
       , ', '
       , date_part('year',latest)
       ,'.'
       )
FROM
(
    SELECT shape
    ,min(occurred::date) as earliest
    ,max(occurred::date) as latest
    ,sum(reports) as reports
    FROM
    (
        SELECT split_part(
                     split_part(
                           split_part(sighting_report,' (Entered',1)
                           ,'Occurred : ',2)
                     ,'Reported',1) as occurred
        ,split_part(
               split_part(sighting_report,'Duration',1)
               ,'Shape: ',2) as shape
        ,count(*) as reports
        FROM ufo
        GROUP BY 1,2
    ) a
    WHERE length(occurred) >= 8
    GROUP BY 1
) aa    
;

concat
---------------------------------------------------------------------
There were 820 reports of teardrop objects. The earliest sighting was 
April 9, 1957 and the most recent was October 3, 2020.
There were 7331 reports of fireball objects. The earliest sighting was 
June 30, 1790 and the most recent was October 5, 2020.
There were 1020 reports of chevron objects. The earliest sighting was 
July 15, 1954 and the most recent was October 3, 2020.

我们可以更有创意地格式化报告的数量,或者添加 coalesce 或 CASE 语句来处理空白的形状名称,例如。尽管这些句子重复而且因此无法与人类(或 AI)写作相比,但如果数据源经常更新,它们将是动态的,并且因此在报告应用程序中非常有用。

除了用于通过连接创建新文本的函数和操作符外,SQL 还有一些专门用于重塑文本的特殊函数,我们将在下文讨论。

重塑文本

正如我们在 第二章 中所看到的,改变数据的形状——无论是从行到列的透视还是反过来,即从列到行的改变——有时是有用的。我们看到了如何通过 GROUP BY 和聚合,或者 UNION 语句来实现这一点。在 SQL 中,还有一些专门用于重塑文本的特殊函数。

文本重塑的一个用例是当实体具有多个行,每个行具有不同的文本值,我们希望将它们组合成一个单一值时。当然,将值合并可能会使其更难分析,但有时候用例需要输出中每个实体只有一个记录。将各个值合并为单个字段允许我们保留细节。string_agg 函数接受两个参数,一个字段或表达式,以及一个分隔符,通常是逗号,但可以是任何所需的分隔符字符。该函数仅聚合非空值,并可以根据需要在函数内使用 ORDER BY 子句控制顺序:

SELECT location
,string_agg(shape,', ' order by shape asc) as shapes
FROM
(
    SELECT 
    case when split_part(
                    split_part(sighting_report,'Duration',1)
                    ,'Shape: ',2) = '' then 'Unknown'
         when split_part(
                    split_part(sighting_report,'Duration',1)
                    ,'Shape: ',2) = 'TRIANGULAR' then 'Triangle'
         else split_part(
                    split_part(sighting_report,'Duration',1),'Shape: ',2)  
         end as shape
    ,split_part(
            split_part(sighting_report,'Shape',1)
            ,'Location: ',2) as location
    ,count(*) as reports
    FROM ufo
    GROUP BY 1,2
) a
GROUP BY 1
;

location        shapes
--------------  -----------------------------------
Macungie, PA    Fireball, Formation, Light, Unknown
Kingsford, MI   Circle, Light, Triangle
Olivehurst, CA  Changing, Fireball, Formation, Oval
...             ...

由于 string_agg 是一个聚合函数,它要求查询中其他字段上有一个 GROUP BY 子句。在 MySQL 中,一个等效的函数是 group_concat,而像 Redshift 和 Snowflake 这样的分析数据库有一个类似的函数称为 listagg

另一个用例是执行与 string_agg 完全相反的操作,而是将单个字段拆分为多行。在不同数据库中,这种实现方式存在很多不一致,甚至是否存在此类函数也不一定。Postgres 提供了一个名为 regexp_split_to_table 的函数,而某些其他数据库则有类似操作的 split_to_table 函数(请查看数据库文档以确定可用性和语法)。regexp_split_to_table 函数接受两个参数,一个字符串值和一个分隔符。分隔符可以是正则表达式,但请注意正则表达式也可以是一个简单的字符串,如逗号或空格字符。该函数然后将值拆分为行:

SELECT 
regexp_split_to_table('Red, Orange, Yellow, Green, Blue, Purple'
                      ,', '); 

regexp_split_to_table
---------------------
Red
Orange
Yellow
Green
Blue
Purple

要拆分的字符串可以包含任何内容,不一定是列表。我们可以使用该函数拆分任何字符串,包括句子。然后,我们可以使用它来查找文本字段中使用最频繁的单词,这是文本分析工作中的一个潜在有用工具。让我们来看看 UFO 目击报告描述中使用最频繁的单词:

SELECT word, count(*) as frequency
FROM
(
    SELECT regexp_split_to_table(lower(description),'\s+') as word
    FROM ufo
) a
GROUP BY 1
ORDER BY 2 desc
;

word  frequency
----  ---------
the   882810
and   477287
a     450223

子查询首先将 description 转换为小写,因为对于本例来说大小写变化并不重要。接下来,使用正则表达式 '\s+' 对字符串进行拆分,该正则表达式在任何一个或多个空格字符上进行拆分。

最常用的单词并不令人惊讶;然而,它们并不特别有用,因为它们只是一般使用的常见单词。要找到更有意义的列表,我们可以删除所谓的停用词。这些只是语言中最常用的单词。一些数据库在所谓的字典中内置了内置列表,但这些实现并不标准。没有单一一致的正确停用词列表,并且通常会根据所需应用调整特定列表;然而,在互联网上有许多常见停用词列表。例如,在这个示例中,我加载了一个包含 421 个常见词的表stop_words,可以在本书的GitHub 网站上找到。使用LEFT JOINstop_words表将这些停用词从结果集中移除,结果集中包含不在该表中的结果。

SELECT word, count(*) as frequency
FROM
(
    SELECT regexp_split_to_table(lower(description),'\s+') as word
    FROM ufo
) a
LEFT JOIN stop_words b on a.word = b.stop_word
WHERE b.stop_word is null
GROUP BY 1
ORDER BY 2 desc
;

word    frequency
------  ---------
light   97071
lights  89537
object  80785
...     ...

最常见的前 10 个单词在 UFO 目击描述中的图 5-11 中绘制成图表。

图 5-11. UFO 目击描述中最常见的单词,不包括停用词

我们可以通过向stop_words表中添加更多常见词或将结果JOIN到描述中以标记包含有趣词汇的内容来进一步复杂化。请注意,regexp_split_to_table和其他数据库中类似的函数可能会很慢,具体取决于所分析记录的长度和数量。

使用 SQL 进行文本构造和重塑可以根据需要以简单或复杂的方式进行。连接、字符串聚合以及字符串分割函数可以单独使用,也可以互相结合,或与其他 SQL 函数和操作符结合使用,以实现所需的数据输出。

结论

尽管在文本分析中,SQL 并不总是第一个提到的工具,但它具有许多强大的函数和操作符,可以完成各种任务。从解析和转换,到查找和替换,再到构造和重塑文本,SQL 既可以用来清洁和准备文本数据,也可以进行分析。

在下一章中,我们将使用 SQL 进行异常检测,这是另一个 SQL 并非总是第一个提到的工具的主题,但它具有令人惊讶的能力。

¹ 由于数据集是在美国创建的,因此采用 mm/dd/yyyy 格式。世界其他地区多使用 dd/mm/yyyy 格式。检查源数据并根据需要调整代码始终是值得的。

第六章:异常检测

异常 是指与同一群体的其他成员不同的东西。在数据中,异常是一条记录、一个观察结果或一个数值,其与其余数据点不同,从而引起关注或怀疑。异常有很多不同的名称,包括 离群值新奇值噪声偏差异常值 等等。在本章节中,我将交替使用 异常离群值 这两个术语,你可能也会在讨论中看到其他术语。异常检测可以是分析的最终目标,也可以是更广泛分析项目中的一步。

异常通常有两种来源:真实事件中的极端或其他异常,或者在数据收集或处理过程中引入的错误。尽管检测离群值的许多步骤不论根源如何都是相同的,但我们选择如何处理特定异常取决于根本原因。因此,理解根本原因并区分这两种原因对分析过程非常重要。

真实事件可能因多种原因产生离群值。异常数据可以标志欺诈、网络入侵、产品结构缺陷、政策漏洞,或者产品使用并非开发人员预期或设想的方式。异常检测广泛用于查找金融欺诈,网络安全也利用这种类型的分析。有时异常数据的产生并非因为有人试图利用系统,而是因为客户以意想不到的方式使用产品。例如,我认识一个人,他使用健身跟踪应用来记录他在赛车场的行程,尽管该应用是为跑步、骑行、步行等活动而设计的。他当时没有找到更好的选项,也没有考虑到在赛车场上记录的速度和距离值与自行车骑行或跑步记录相比有多么异常。当可以追踪到真实过程的异常时,决定如何处理这些异常需要对分析过程、领域知识、使用条款以及有时管理产品的法律制度有很好的理解。

数据也可能因为收集或处理中的错误而包含异常。手动输入的数据以错字和不正确的数据而臭名昭著。表单、字段或验证规则的更改可能引入意外值,包括空值。常见的行为追踪网络和移动应用程序,但是任何关于何时如何进行记录的更改都可能引入异常。我花了足够多的时间诊断指标变化,我已经学会了事先询问是否最近更改了任何记录。数据处理可能会因为一些值被错误地过滤、处理步骤未能完成,或者数据被多次加载而产生重复而引入异常值。当异常来自数据处理时,我们通常可以更有信心地纠正或丢弃这些值。当然,如果可能的话,修复上游数据输入或处理总是个好主意,以防止未来的质量问题。

在本章中,我首先讨论了使用 SQL 进行此类分析的一些原因及其局限性。然后,我会介绍地震数据集,该数据集将用于本章其余部分的示例。接着,我将介绍我们在 SQL 中用于检测异常值的基本工具。然后我会讨论我们可以应用这些工具来找到的各种异常形式。一旦我们检测并理解了异常,下一步就是决定如何处理它们。异常并不总是问题,比如在欺诈检测、网络攻击检测和健康系统监测中。本章的技术也可以用于检测异常好的客户或者市场营销活动,或者客户行为的积极变化。有时异常检测的目标是将异常传递给其他人或机器处理,但通常这只是更广泛分析的一步,所以最后我会总结各种纠正异常的选项。

SQL 对异常检测的能力和限制

SQL 是一个多才多艺、强大的语言,适用于许多数据分析任务,尽管它不能做到所有事情。在执行异常检测时,SQL 有许多优点,也有一些缺点,使得其他语言或工具对某些任务更合适。

当数据集已经在数据库中时,SQL 是值得考虑的选项,正如我们之前在第 3 和 5 章节中分别看到的时间序列和文本分析。SQL 利用数据库的计算能力快速执行大量记录的计算。特别是对于大型数据表,将数据从数据库转移到另一个工具是耗时的。在 SQL 中进行异常检测作为更大分析步骤的一部分是有意义的。通过分析 SQL 中编写的代码,可以理解为什么特定记录被标记为异常值,并且即使数据流入数据库发生变化,SQL 也能保持一致。

但是,SQL 并没有像 R 和 Python 等语言开发的软件包中那样丰富的统计学功能。SQL 有一些标准的统计函数,但对于一些数据库来说,额外的复杂统计计算可能会太慢或者太复杂。对于需要非常快速响应的使用场景,比如欺诈或入侵检测,分析数据库中的数据可能不合适,因为加载数据通常会有延迟,特别是对于分析数据库而言。一种常见的工作流是使用 SQL 进行初始分析,并确定典型的最小值、最大值和平均值,然后使用流式服务或特殊的实时数据存储开发更实时的监控。然而,检测异常模式类型,然后在流式服务或特殊的实时数据存储中实施,也是一个选择。最后,SQL 代码是基于规则的,正如我们在 第五章 中看到的。它非常适合处理已知的一组条件或标准,但 SQL 不会自动调整以适应快速变化的对手所见到的变化模式类型。对于这些应用程序,机器学习方法及其关联的语言通常是更好的选择。

现在我们已经讨论了 SQL 的优点以及何时使用它而不是另一种语言或工具,让我们先看一下本章节示例中将使用的数据,然后再进入代码本身。

数据集

本章示例的数据是由美国地质调查局(USGS)从 2010 年到 2020 年记录的所有地震事件组成的一组记录。USGS 提供多种格式的数据,包括实时数据源,网址为https://earthquake.usgs.gov/earthquakes/feed

数据集包含约 150 万条记录。每条记录代表一个单独的地震事件,包括时间戳、位置、震级、深度以及信息来源等信息。数据的示例显示在 图 6-1 中。美国地质调查局网站上提供了完整的 数据字典

图 6-1. earthquakes数据样本

地震是由地球表面上存在的构造板块之间的断层突然滑动引起的。这些板块边缘的位置经历比其他地方更多且更剧烈的地震。所谓的“火环”是沿太平洋边缘的一个地区,这个地区发生了许多地震。该地区内的各个位置,包括加利福尼亚州、阿拉斯加州、日本和印度尼西亚,将在我们的分析中频繁出现。

震级是地震在其源头的大小测量,由其地震波测量。震级记录在对数尺度上,这意味着震级 5 的地震振幅是震级 4 地震的 10 倍。地震的实际测量是迷人的,但超出了本书的范围。如果您想了解更多信息,可以从美国地质调查局(USGS)网站开始。

检测异常值

尽管异常值或异常值——与其他数据极为不同的数据点的概念似乎很简单,但实际在任何特定数据集中找到一个的挑战很大。第一个挑战涉及知道何时一个值或数据点是常见的或罕见的,第二个是设置标记这条分界线两侧值的阈值。当我们浏览earthquakes数据时,我们将分析深度和震级,以便了解哪些值是正常的,哪些是不寻常的。

一般来说,数据集越大或越完整,我们就越容易判断什么是真正的异常。在某些情况下,我们有标记或“地面真相”值可供参考。标签通常是数据集中指示记录是正常还是异常的列。地面真相可以从行业或科学来源获得,也可以从过去的分析中获得,例如,告诉我们任何大于 7 级的地震都是异常情况。在其他情况下,我们必须查看数据本身并应用合理的判断。在本章的其余部分,我们假设我们有足够大的数据集来做到这一点,尽管当然我们可以查阅有关典型和极端地震震级的外部参考资料。

我们使用数据集本身来检测异常值的工具分为几类。首先,我们可以对数据中的值进行排序或ORDER BY。这可以选择性地与各种GROUP BY子句结合使用,以按频率查找异常值。其次,我们可以使用 SQL 的统计函数来查找值范围两端的极端值。最后,我们可以绘制数据并进行视觉检查。

使用排序查找异常值

找出异常值的基本工具之一是对数据进行排序,可以通过ORDER BY子句完成。ORDER BY的默认行为是升序排序(ASC)。要按降序排序,请在列名后添加DESCORDER BY子句可以包括一个或多个列,并且每个列可以独立于其他列按升序或降序排序。排序从指定的第一列开始。如果指定了第二列,则第一次排序的结果将按第二列进行排序(保留第一次排序),依此类推,直到子句中的所有列都排序完毕。

提示

由于排序发生在数据库计算查询的其余部分之后,许多数据库允许您按列号而不是按名称引用查询列。SQL Server 是一个例外;它需要完整的名称。我更喜欢编号语法,因为它可以生成更紧凑的代码,特别是当查询列包含冗长的计算或函数语法时。

例如,我们可以按earthquakes表中的mag(震级)排序:

SELECT mag
FROM earthquakes
ORDER BY 1 desc
;

mag
------
(null)
(null)
(null)
...

这将返回若干行空值。让我们注意,数据集可能包含幅度的空值——这本身可能就是一个异常值。我们可以排除空值:

SELECT mag
FROM earthquakes
WHERE mag is not null
ORDER BY 1 desc
;

mag
---
9.1
8.8
8.6
8.3

只有一个大于 9 的值,而且只有另外两个大于 8.5 的值。在许多情境中,这些看起来可能并不是特别大的值。然而,通过一点关于地震的领域知识,我们可以认识到这些值实际上都非常大且不寻常。USGS 提供了一份世界上最大 20 次地震的列表。其中所有地震的震级都大于 8.4,而只有五次地震的震级大于 9.0,其中三次发生在我们数据集涵盖的 2010 至 2020 年期间。

另一种考虑值是否在数据集中异常的方法是计算它们的频率。我们可以统计id字段并按mag分组,以找出每个震级的地震数量。然后将每个震级的地震数量除以使用sum窗口函数找到的总地震数量。所有窗口函数都需要PARTITION BY和/或ORDER BY子句的OVER子句。由于分母应计算所有记录,我添加了PARTITION BY 1,这是一种强制数据库使其成为窗口函数但仍从整个表中读取的方法。最后,结果集按震级ORDER BY 排序:

SELECT mag
,count(id) as earthquakes
,round(count(id) * 100.0 / sum(count(id)) over (partition by 1),8) 
 as pct_earthquakes
FROM earthquakes
WHERE mag is not null
GROUP BY 1
ORDER BY 1 desc
;

mag  earthquakes  pct_earthquakes
---  -----------  ---------------
9.1  1            0.00006719
8.8  1            0.00006719
8.6  1            0.00006719
8.3  2            0.00013439
...  ...          ... 
6.9  53           0.00356124
6.8  45           0.00302370
6.7  60           0.00403160
...  ...          ...

只有一个地震震级超过 8.5,但有两次注册了 8.3。到 6.9 这个数值时,地震次数已经有两位数了,但这仍然只代表数据的非常小的百分比。在我们的调查中,我们还应检查排序的另一端,即最小值,通过升序而不是降序排序:

SELECT mag
,count(id) as earthquakes
,round(count(id) * 100.0 / sum(count(id)) over (partition by 1),8) 
 as pct_earthquakes
FROM earthquakes
WHERE mag is not null
GROUP BY 1
ORDER BY 1
;

mag   earthquakes  pct_earthquakes
---    -----------  ---------------
-9.99  258          0.01733587
-9     29           0.00194861
-5     1            0.00006719
-2.6   2            0.00013439
...    ...          ...

在值的低端,–9.99 和–9 出现的频率比我们预期的要高。尽管我们不能取零或负数的对数,当参数大于零且小于一时,对数可以为负。例如,log(0.5)约等于–0.301。值–9.99 和–9 代表极小的地震震级,我们可能怀疑这样小的地震是否真的可以被检测到。考虑到这些值的频率,我怀疑它们代表一个未知值,而不是一个真正微小的地震,因此我们可以认为它们是异常值。

除了对整体数据进行排序外,还可以通过GROUP BY一个或多个属性字段来查找数据子集内的异常。例如,我们可能希望检查在place字段中特定地理区域记录的最高和最低震级:

SELECT place, mag, count(*)
FROM earthquakes
WHERE mag is not null
 and place = 'Northern California'
GROUP BY 1,2
ORDER BY 1,2 desc
;

place                mag   count
-------------------  ----  -----
Northern California  5.61
Northern California  4.73  1
Northern California  4.51  1
...                  ...   ...
Northern California  -1.1  7
Northern California  -1.2  2
Northern California  -1.6  1

“北加利福尼亚”是数据集中最常见的place,仅检查其子集时,我们可以看到其高低值远不及整个数据集那么极端。整体而言,5.0 级以上的地震并不罕见,但对于“北加利福尼亚”来说,它们是异常值。

计算百分位数和标准偏差以发现异常

对数据进行排序并选择性地分组,然后在视觉上审查结果是发现异常的有用方法,特别是当数据具有非常极端值时。然而,如果没有领域知识,一个 9.0 级的地震是这样的异常可能并不明显。量化数据点的极端性为分析增加了另一层严谨性。有两种方法可以做到这一点:使用百分位数或标准偏差。

百分位数表示在分布中小于特定值的值的比例。分布的中位数是一个值,其中一半的人口有较低的值,另一半有较高的值。中位数如此常用,以至于它在许多但不是所有数据库中都有自己的 SQL 函数median。还可以计算其他百分位数。例如,我们可以找到第 25 百分位数,其中 25%的值较低,75%较高,或者第 89 百分位数,其中 89%的值较低,11%较高。百分位数通常在学术背景下发现,例如标准化测试,但可以应用于任何领域。

SQL 有一个窗口函数,percent_rank,它返回分区内每行的百分位数。与所有窗口函数一样,排序方向由ORDER BY语句控制。类似于rank函数,percent_rank不接受任何参数;它适用于查询返回的所有行。基本形式如下:

percent_rank() over (partition by ... order by ...)

PARTITION BYORDER BY 都是可选的,但函数需要OVER子句中的内容,并且指定排序总是一个好主意。要找到每个地震的每个地点的震级的百分位数,我们可以首先在子查询中为每一行计算percent_rank,然后在外部查询中计算每个震级的出现次数。注意,首先计算percent_rank非常重要,在进行任何聚合之前,以便在计算中考虑重复值:

SELECT place, mag, percentile
,count(*)
FROM
(
    SELECT place, mag
    ,percent_rank() over (partition by place order by mag) as percentile
    FROM earthquakes
    WHERE mag is not null
    and place = 'Northern California'
) a
GROUP BY 1,2,3
ORDER BY 1,2 desc
;

place                mag   percentile             count
-------------------  ----  ---------------------  -----  
Northern California  5.6   1.0                    1
Northern California  4.73  0.9999870597065141     1
Northern California  4.51  0.9999741194130283     1
...                  ...   ...                    ...
Northern California  -1.1  3.8820880457568775E-5  7
Northern California  -1.2  1.2940293485856258E-5  2
Northern California  -1.6  0.0                    1

在北加州地区,震级为 5.6 的地震的百分位数为 1,即 100%,表示所有其他数值均小于此值。震级为-1.6 的地震的百分位数为 0,表示没有比这个更小的数据点。

除了找到每行的确切百分位数外,SQL 还可以将数据集划分为指定数量的桶,并返回每行所属的桶,使用称为ntile的函数。例如,我们可能希望将数据集分成 100 个桶:

SELECT place, mag
,ntile(100) over (partition by place order by mag) as ntile
FROM earthquakes
WHERE mag is not null
and place = 'Central Alaska'
ORDER BY 1,2 desc
;

place           mag  ntile
--------------  ----  -----  
Central Alaska  5.4   100
Central Alaska  5.3   100
Central Alaska  5.2   100
...             ...   ...  
Central Alaska  1.5   79
...             ...   ...    
Central Alaska  -0.5  1
Central Alaska  -0.5  1
Central Alaska  -0.5  1

查看“中央阿拉斯加”的结果时,我们发现大于 5 的三个地震处于 100 百分位数,1.5 位于 79 百分位数,最小值-0.5 位于第一百分位数。计算这些值后,我们可以使用maxmin找到每个 ntile 的边界。对于这个例子,我们将使用四个 ntile 来简化显示,但ntile参数允许任何正整数:

SELECT place, ntile
,max(mag) as maximum
,min(mag) as minimum
FROM
(
    SELECT place, mag
    ,ntile(4) over (partition by place order by mag) as ntile
    FROM earthquakes
    WHERE mag is not null
    and place = 'Central Alaska'
) a
GROUP BY 1,2
ORDER BY 1,2 desc
;

place           ntile  maximum  minimum
--------------  -----  -------  -------
Central Alaska  4      5.4      1.4
Central Alaska  3      1.4      1.1
Central Alaska  2      1.1      0.8
Central Alaska  1      0.8      -0.5

表示 75 到 100 百分位数的最高 ntile 为 4,具有最宽的范围,从 1.4 到 5.4。另一方面,包括 ntile 2 和 3 的中间 50%的值范围仅从 0.8 到 1.4。

除了为每一行找到百分位数或 ntile 之外,我们还可以计算查询结果集中特定百分位数。为此,我们可以使用percentile_cont函数或percentile_disc函数。两者都是窗口函数,但与先前讨论的其他窗口函数语法略有不同,因为它们需要一个WITHIN GROUP子句。函数的形式是:

percentile_cont(*numeric*) within group (order by *field_name*) over (partition by
*field_name*)

数字是一个介于 0 和 1 之间的值,表示要返回的百分位数。例如,0.25 返回第 25 百分位数。ORDER BY 子句指定要从中返回百分位数的字段,以及排序方式。ASCDESC 可以选择性地添加,ASC 是默认值,就像 SQL 中所有ORDER BY 子句一样。OVER (PARTITION BY...) 子句是可选的(令人困惑的是,一些数据库不支持它,请在遇到错误时查看您的文档)。

percentile_cont函数将返回一个插值(计算)值,该值对应于精确的百分位数,但在数据集中可能不存在。另一方面,percentile_disc(不连续百分位数)函数返回数据集中最接近请求百分位数的值。对于大数据集或具有相当连续值的数据集,这两个函数的输出通常几乎没有实际差异,但考虑哪个更适合您的分析是值得的。让我们看一个示例,看看这在实践中是什么样子。我们将计算中阿拉斯加所有非空大小的第 25、50(或中位数)和 75 百分位数大小:

SELECT 
percentile_cont(0.25) within group (order by mag) as pct_25
,percentile_cont(0.5) within group (order by mag) as pct_50
,percentile_cont(0.75) within group (order by mag) as pct_75
FROM earthquakes
WHERE mag is not null
and place = 'Central Alaska'
;

pct_25  pct_50  pct_75
------  ------  ------
0.8     1.1     1.4

查询返回请求的百分位数,总结在数据集中。请注意,这些值对应于在上一个例子中计算的 ntiles 1、2 和 3 的最大值。可以通过在ORDER BY子句中更改字段来计算同一查询中不同字段的百分位数:

SELECT 
percentile_cont(0.25) within group (order by mag) as pct_25_mag
,percentile_cont(0.25) within group (order by depth) as pct_25_depth
FROM earthquakes
WHERE mag is not null
and place = 'Central Alaska'
;

pct_25_mag  pct_25_depth
----------  ------------
0.8         7.1

与其他窗口函数不同,当查询中存在其他字段时,percentile_contpercentile_disc函数在查询级别需要GROUP BY子句。例如,如果我们想要考虑阿拉斯加内的两个地区,因此包括place字段,查询还必须在GROUP BY中包括它,并且百分位数是按place计算的:

SELECT place
,percentile_cont(0.25) within group (order by mag) as pct_25_mag
,percentile_cont(0.25) within group (order by depth) as pct_25_depth
FROM earthquakes
WHERE mag is not null
and place in ('Central Alaska', 'Southern Alaska')
GROUP BY place
;

place            pct_25_mag  pct_25_depth
---------------  ----------  ------------
Central Alaska   0.8         7.1
Southern Alaska  1.2         10.1

使用这些函数,我们可以找到分析所需的任何百分位数。由于中位数值通常被计算,许多数据库实现了一个只有一个参数的median函数,用于计算中位数的字段。这是一种方便且明显简单得多的语法,但请注意,如果median函数不可用,也可以使用percentile_cont实现同样的功能。

提示

percentilemedian函数在大数据集上可能速度较慢且计算密集。这是因为数据库通常必须在内存中对所有记录进行排序和排名。一些数据库供应商已实现了近似版本的函数,例如approximate_percentile,它们要快得多,并返回与计算整个数据集的函数非常接近的结果。

找到数据集的百分位数或 ntiles 可以使我们对异常添加一些量化。我们将在本章后面看到,这些值还为我们处理数据集中的异常提供了一些工具。然而,由于百分位数始终在 0 和 100 之间缩放,它们并不能表达某些值有多么不寻常。为此,我们可以转向 SQL 支持的额外统计函数。

为了衡量数据集中极端值的程度,我们可以使用标准偏差。标准偏差是一组值变化程度的度量。较低的值表示变化较小,而较高的数字表示变化较大。当数据围绕均值正态分布时,大约 68% 的值位于距均值的一标准偏差范围内,约 95% 位于两个标准偏差内。标准偏差计算公式为从均值差异总和的平方根除以观察数:

(x i -μ) 2 / N

在这个公式中,x[i] 是一个观察值,μ 是所有观察值的平均值,∑ 表示应对所有值求和,N 是观察值的数量。有关标准偏差如何推导的更多信息,请参考任何良好的统计文本或在线资源¹。

大多数数据库都有三个标准偏差函数。stddev_pop 函数用于找出总体的标准偏差。如果数据集代表整个人口,通常使用 stddev_popstddev_samp 找出样本的标准偏差,与上述公式不同之处在于除以 N - 1 而不是 N。这会增加标准偏差,反映仅使用整体人口的样本时的精度损失。在许多数据库中可用的 stddev 函数与 stddev_samp 函数相同,可以简单地使用它因为长度较短。如果你处理的是来自调查或研究的样本数据,例如较大人口中的样本数据,应使用 stddev_sampstddev。实际上,在处理大数据集时,stddev_popstddev_samp 的结果通常几乎没有差异。例如,在 earthquakes 表的 150 万记录中,仅在小数点后五位数值有所不同:

SELECT stddev_pop(mag) as stddev_pop_mag
,stddev_samp(mag) as stddev_samp_mag
FROM earthquakes
;

stddev_pop_mag        stddev_samp_mag
--------------------  --------------------
1.273605805569390395  1.273606233458381515

这些差异很小,在大多数实际应用中,使用哪个标准偏差函数并不重要。

使用此函数,我们现在可以计算数据集中每个值相对于均值的标准偏差数。这个值称为z-分数,是标准化数据的一种方式。高于平均值的值有正的 z-分数,低于平均值的值有负的 z-分数。图 6-2 显示了 z-分数和标准偏差与正态分布的关系。

图 6-2. 正态分布的标准偏差和 z 分数

要找到地震的 Z 分数,首先在子查询中计算整个数据集的平均值和标准差。然后使用笛卡尔JOIN将这些值与数据集JOIN回来,以便平均值和标准差值与每个地震行JOIN。这是通过1 = 1语法实现的,因为大多数数据库要求指定某些JOIN条件。

在外部查询中,从每个单独的震级中减去平均震级,然后除以标准差:

SELECT a.place, a.mag
,b.avg_mag, b.std_dev
,(a.mag - b.avg_mag) / b.std_dev as z_score
FROM earthquakes a
JOIN
(
    SELECT avg(mag) as avg_mag
    ,stddev_pop(mag) as std_dev
    FROM earthquakes
    WHERE mag is not null
) b on 1 = 1
WHERE a.mag is not null
ORDER BY 2 desc
;

place                                   mag  avg_mag  std_dev  z_score
--------------------------------------  ---  -------  -------  -------
2011 Great Tohoku Earthquake, Japan     9.1   1.6251   1.2736  5.8691
offshore Bio-Bio, Chile                 8.8   1.6251   1.2736  5.6335
off the west coast of northern Sumatra  8.6   1.6251   1.2736  5.4765
...                                     ...   ...      ...     ...
Nevada                                  -2.5  1.6251   1.2736  -3.2389
Nevada                                  -2.6  1.6251   1.2736  -3.3174
Nevada                                  -2.6  1.6251   1.2736  -3.3174

最大的地震的 Z 分数接近 6,而最小的(排除看起来是数据输入异常的-9 和-9.99 地震)的 Z 分数接近 3。我们可以得出结论,最大的地震比低端的那些更为极端的异常值。

用图表直观地查找异常值

除了对数据进行排序和计算百分位数和标准差以找到异常值外,将数据可视化为几种图表格式之一也有助于发现异常值。正如我们在前面章节中所见,图表的一个优势是它们能够以紧凑的形式总结和呈现许多数据点。通过检查图表,我们通常可以发现模式和异常值,如果仅考虑原始输出,可能会被忽略。最后,图表有助于描述数据以及与数据相关的任何潜在问题,特别是异常值,向其他人传达信息。

在本节中,我将介绍三种对异常检测有用的图表类型:条形图、散点图和箱线图。生成这些图表所需的 SQL 很简单,尽管根据所用软件的能力和限制,你可能需要使用前面章节讨论过的数据透视策略。任何主要的商业智能工具或电子表格软件,以及诸如 Python 或 R 的编程语言,都能够生成这些图表类型。本节中的图表是使用 Python 和 Matplotlib 创建的。

条形图用于绘制字段值的直方图或分布图,并且对于描述数据和发现异常值都很有用。所有值的全范围沿一个轴绘制,每个值的出现次数沿另一个轴绘制。极高和极低的值是有趣的,以及绘图的形状。我们可以快速确定分布是否近似正态(围绕峰值或平均值对称),是否具有其他类型的分布,或者是否在特定值处有峰值。

要为地震震级绘制直方图,首先创建一个数据集,分组震级并计算地震数量。然后绘制输出,如图 6-3 所示。

SELECT mag
,count(*) as earthquakes
FROM earthquakes
GROUP BY 1
ORDER BY 1
;

mag    earthquakes
-----  -----------
-9.99  258
-9     29
-5     1
...    ...

图 6-3. 地震震级分布

该图的范围从–10.0 到+10.0,这在我们之前对数据的探索中是有意义的。它峰值大约在 1.1 到 1.4 范围内,每个震级约有 40,000 次地震,但它在约 4.4 处有第二个峰值,约有 20,000 次地震。我们将在下一节关于异常形式的部分探讨这第二个峰值的原因。在这张图中很难看到极端值,因此我们可能希望放大图表的某个部分,如图 6-4:

图 6-4. 地震震级分布的放大视图,聚焦于最高震级

在这里,这些非常高强度地震的频率更容易看到,从低 7 到超过 8 的值从超过 10 下降到仅为 1。幸运的是,这些地震非常罕见。

第二种用于描述数据和发现异常值的图表是散点图。当数据集包含至少两个感兴趣的数值时,散点图是合适的。x 轴显示第一个数据字段的值范围,y 轴显示第二个数据字段的值范围,在数据集中的每对 x 和 y 值上绘制一个点。例如,我们可以绘制数据集中地震的震级与深度之间的散点图。首先,查询数据以创建每对值的数据集。然后像图 6-5 那样绘制输出:

SELECT mag, depth
,count(*) as earthquakes
FROM earthquakes
GROUP BY 1,2
ORDER BY 1,2
;

mag    depth  earthquakes
-----  -----  -----------
-9.99  -0.59  1
-9.99  -0.35  1
-9.99  -0.11  1
...    ...    ...

图 6-5. 震级和深度的散点图

在这张图中,我们可以看到相同幅度的地震,现在根据深度绘制,深度从零以下到大约 700 公里不等。有趣的是,高深度值超过 300 对应于大约 4 以上的震级。也许只有当这些深部地震达到最小震级后才能检测到。请注意,由于数据量大,我采取了一种捷径,将值按照震级和深度组合分组,而不是绘制所有 150 万数据点。地震的计数可以用来调整散点图中每个圆的大小,例如图 6-6,该图放大了震级从 4.0 到 7.0,深度从 0 到 50 公里的范围。

图 6-6. 震级和深度的散点图,放大并且圆圈的大小根据地震数量调整

在查找和分析异常值时非常有用的第三种图表类型是箱线图,也称为箱须图。这些图表总结了值范围中间的数据,同时保留了异常值。该图表类型以中间的箱子或矩形命名。形成矩形底部的线位于第 25 百分位值,形成顶部的线位于第 75 百分位值,而中间的线位于第 50 百分位值,即中位数。百分位数在前一节中已经讨论过了。箱线图的“盒须”是从箱子伸出的线,通常延伸到四分位距的 1.5 倍。四分位距简单来说是第 75 百分位值与第 25 百分位值之间的差值。超出盒须的任何值都会作为异常值绘制在图表上。

提示

无论您使用哪种软件或编程语言来绘制箱线图,都会自动处理百分位数和四分位距的计算。许多软件还提供基于标准差(如均值上下的一个标准差)或更广泛百分位(如第 10 和第 90 百分位)的选项来绘制盒须。计算始终围绕中点对称(例如均值上下的一个标准差),但上下盒须的长度可能会因数据而异。

典型情况下,所有值都会在箱线图中绘制。由于数据集非常庞大,例如,我们将查看包含“日本”在place字段中的 16,036 次地震子集。首先,使用 SQL 创建数据集,这是一个简单的SELECT,选择所有符合筛选条件的mag值:

SELECT mag
FROM earthquakes
WHERE place like '%Japan%'
ORDER BY 1
;

mag
---
2.7
3.1
3.2
...

然后,在我们选择的图形软件中创建一个箱线图,如图 6-7 所示。

图 6-7. 展示了日本地震震级分布的箱线图

尽管绘图软件通常会提供这些信息,我们还可以使用 SQL 查找箱线图的关键值:

SELECT ntile_25, median, ntile_75
,(ntile_75 - ntile_25) * 1.5 as iqr
,ntile_25 - (ntile_75 - ntile_25) * 1.5 as lower_whisker
,ntile_75 + (ntile_75 - ntile_25) * 1.5 as upper_whisker
FROM
(
    SELECT 
    percentile_cont(0.25) within group (order by mag) as ntile_25
    ,percentile_cont(0.5) within group (order by mag) as median
    ,percentile_cont(0.75) within group (order by mag) as ntile_75
    FROM earthquakes
    WHERE place like '%Japan%'
) a
;

ntile_25  median  ntile_75  iqr  lower_whisker   upper_whisker
--------  ------  --------  ----  -------------  -------------
4.3       4.5     4.7       0.60  3.70           5.30

日本地震的中位数为 4.5,盒须从 3.7 延伸至 5.3。绘制的圆圈代表异常地震,包括小型和大型地震。2011 年的大东北地震,震级为 9.1,显然是一个异常值,即使在日本经历的较大地震中也是如此。

警告

在我的经验中,箱线图是向那些没有统计背景或整天制作和查看可视化的人解释起来比较困难的可视化之一。四分位距尤其是一个特别令人困惑的概念,尽管异常值的概念似乎对大多数人来说是有意义的。如果你不确定你的观众绝对知道如何解读箱线图,请花时间用清晰但不过于技术化的术语来解释它。我保留了一幅像图 6-8 这样解释箱线图各部分的图画,并随我的工作一起发送,以防万一我的观众需要复习。

图 6-8. 箱线图各部分示意图

箱线图也可以用来比较数据的不同分组,进一步识别和诊断异常值出现的位置。例如,我们可以比较日本不同年份的地震情况。首先将time字段的年份添加到 SQL 输出中,然后绘制图表,如图 6-9 所示:

SELECT date_part('year',time)::int as year
,mag
FROM earthquakes
WHERE place like '%Japan%'
ORDER BY 1,2
;

year  mag
----  ---
2010  3.6
2010  3.7
2010  3.7
...   ...

图 6-9. 日本地震震级的箱线图,按年份划分

尽管箱子的中位数和范围从一年到一年会有些波动,但它们始终在 4 到 5 之间。日本每年都经历着大的离群地震,至少有一次大于 6.0,而在六年中,至少有一次地震达到或超过 7.0。日本无疑是一个地震活动非常频繁的地区。

条形图、散点图和箱线图通常用于检测和描述数据集中的异常值。它们使我们能够快速理解大量数据的复杂性,并开始讲述背后的故事。除了排序、百分位数和标准偏差之外,图表是异常检测工具包的重要组成部分。有了这些工具,我们准备讨论异常可能采取的各种形式,除了我们到目前为止见过的形式。

异常形式

异常可以呈现各种形状和大小。在本节中,我将讨论三种一般类型的异常:值、计数或频率以及存在或不存在。这些是调查任何数据集的起点,无论是作为概要性练习还是因为怀疑存在异常值。离群值和其他异常值通常特定于特定领域,因此总体而言,你对数据生成的方式和原因了解得越多,就越好。然而,这些发现异常的模式和技术是调查的良好起点。

异常值

可能是最常见的异常类型,也是这个主题首先想到的事情,就是单个值要么是极高的或极低的离群值,要么是分布中间的值非常不寻常。

在上一节中,我们探讨了几种寻找异常值的方法,包括排序、百分位数、标准差和绘图。我们发现地震数据集在震级方面既有异常大的值,也有异常小的值。震级还包含不同数量的有效数字,或者小数点后的数字。例如,我们可以查看围绕 1 附近的一些数值子集,并找到数据集中反复出现的模式:

SELECT mag, count(*)
FROM earthquakes
WHERE mag > 1
GROUP BY 1
ORDER BY 1
limit 100
;

mag         count
----------  -----
...         ... 
1.08        3863
1.08000004  1
1.09        3712
1.1         39728
1.11        3674
1.12        3995
....        ...

偶尔会有 8 个有效数字的数值出现。许多数值有两个有效数字,但只有一个有效数字更为普遍。这可能是由于收集震级数据的仪器精度不同所致。此外,当第二个有效数字为零时,数据库不显示第二个有效数字,因此“1.10”看起来简单地显示为“1.1”。然而,“1.1”处的大量记录表明这不仅仅是一个显示问题。根据分析目的,我们可能需要通过四舍五入来调整数值,使其具有相同数量的有效数字。

除了找到异常值之外,理解它们发生的原因或与异常相关的其他属性也是有用的。这是创造性和数据探索工作的体现。例如,数据集中有 1,215 条记录的深度值超过 600 公里。我们可能想知道这些异常值发生在哪里或者它们是如何收集的。让我们来看看来源,我们可以在net(表示网络)字段中找到。

SELECT net, count(*)
FROM earthquakes
WHERE depth > 600
GROUP BY 1
;

net  count
---  -----
us   1215

美国地质调查局(USGS 国家地震信息中心,PDE)网站指示了这一信息来源。然而,这并不是特别详细,因此让我们来检查一下place值,其中包含地震的位置信息:

SELECT place, count(*)
FROM earthquakes
WHERE depth > 600
GROUP BY 1
;

place                           count
------------------------------  -----
100km NW of Ndoi Island, Fiji   1
100km SSW of Ndoi Island, Fiji  1
100km SW of Ndoi Island, Fiji   1
...                             ...

目视检查表明,这些非常深的地震多发生在斐济的Ndoi 岛附近。然而,该地点包括距离和方向组成部分,如“100km NW of”,这使得总结变得更加困难。我们可以应用一些文本解析技术,集中关注地点本身,以获取更好的见解。

SELECT 
case when place like '% of %' then split_part(place,' of ',2) 
     else place end as place_name
,count(*)
FROM earthquakes
WHERE depth > 600
GROUP BY 1
ORDER BY 2 desc
;

place_name         count
-----------------  -----
Ndoi Island, Fiji  487
Fiji region        186
Lambasa, Fiji      140
...                ...

现在我们可以更加确信,大部分非常深的数值记录来自斐济某处地震,特别是集中在小火山岛 Ndoi 周围。分析可以进一步复杂化,例如通过解析文本将所有记录在更大区域内的地震分组在一起,这将揭示在斐济之后,其他非常深的地震也发生在瓦努阿图和菲律宾附近。

异常可以以拼写错误、大小写变化或其他文本错误的形式出现。发现这些错误的难易程度取决于字段的不同值的数量,或者基数。在应用lowerupper函数时,大小写的差异可以通过计算不同的值和应用函数后的不同值来检测:

SELECT count(distinct type) as distinct_types
,count(distinct lower(type)) as distinct_lower
FROM earthquakes
;

distinct_types  distinct_lower
--------------  --------------
25              24

type字段有 24 个不同的值,但是有 25 种不同的形式。为了找到具体的类型,我们可以进行计算,标记那些小写形式与实际值不匹配的值。包括每种形式的记录计数将有助于在后续决定如何处理这些值时提供背景信息:

SELECT type
,lower(type)
,type = lower(type) as flag
,count(*) as records
FROM earthquakes
GROUP BY 1,2,3
ORDER BY 2,4 desc
;

type       lower      flag   records
---------  ---------  -----  -------
...        ...        ...    ...
explosion  explosion  true   9887
ice quake  ice quake  true   10136
Ice Quake  ice quake  false  1
...        ...        ...    ...

“Ice quake”的异常值很容易识别,因为它是唯一一个返回false的标志计算值。由于只有一条记录包含此值,相比于低级别形式的 10,136 条记录,我们可以假设它可以与其他记录分组在一起。可以应用其他文本函数,如trim,如果我们怀疑值包含额外的前导或尾随空格,或者replace,如果我们怀疑某些拼写有多个形式,比如数字“2”和单词“two”。

拼写错误可能比其他变体更难发现。如果存在已知的正确值和拼写的集合,可以通过与包含这些值的表进行外连接或使用 CASE 语句与 IN 列表来验证数据。在任何情况下,目标是标记意外或无效的值。如果没有这样的正确值集合,我们的选择通常是应用领域知识或进行合理的猜测。在earthquakes表中,我们可以查看type值,只有少数记录,然后尝试确定是否有其他更常见的值可以替代:

SELECT type, count(*) as records
FROM earthquakes
GROUP BY 1
ORDER BY 2 desc
;

type                        records
--------------------------  -------
...                         ...
landslide                   15
mine collapse               12
experimental explosion      6
building collapse           5
...                         ...
meteorite                   1
accidental explosion        1
collapse                    1
induced or triggered event  1
Ice Quake                   1
rockslide                   1

我们之前看过“冰震”并认为它很可能与“ice quake”相同。对于“rockslide”只有一条记录,尽管我们可能认为这足够接近另一个值“landslide”,后者有 15 条记录。“崩溃”更加模糊,因为数据集包含“矿井坍塌”和“建筑物坍塌”两种情况。我们对这些值的处理,或者是否需要处理,取决于分析的目标,我将在“处理异常”中讨论。

异常计数或频率

有时异常不是以单个值的形式出现,而是以数据中的模式或活动簇群的形式出现。例如,在电子商务网站上,客户每小时花费 100 美元可能并不罕见,但是同一个客户在 48 小时内每小时都花费 100 美元几乎肯定是异常。

有许多维度可以表明活动的集群可能指示异常,其中许多依赖于数据的上下文。时间和位置在许多数据集中都很常见,并且是earthquakes数据集的特征,因此我将使用它们来说明本节中的技术。请记住,这些技术通常也可以应用于其他属性。

在短时间内频繁发生的事件可能表明异常活动。这可能是好事,例如当名人突然推广某种产品时导致该产品销量激增。它们也可能是坏事,例如当异常的峰值表明欺诈性信用卡使用或试图通过大量流量使网站崩溃时。为了理解这些类型的异常以及是否存在与正常趋势的偏差,我们首先应用适当的聚合,然后使用本章早期介绍的技术,以及第三章中讨论的时间序列分析技术。

在接下来的示例中,我将逐步介绍一系列步骤和查询,这些步骤和查询将帮助我们理解正常模式并寻找异常模式。这是一个迭代过程,利用数据分析、领域知识以及先前查询结果的洞察来引导每一步。我们将从按年计算地震数量开始,可以通过将time字段截断到年级别并计数记录来实现。对于不支持date_trunc的数据库,考虑使用extracttrunc代替:

SELECT date_trunc('year',time)::date as earthquake_year
,count(*) as earthquakes
FROM earthquakes
GROUP BY 1
;

earthquake_year  earthquakes
---------------  -----------
2010-01-01       122322
2011-01-01       107397
2012-01-01       105693
2013-01-01       114368
2014-01-01       135247
2015-01-01       122914
2016-01-01       122420
2017-01-01       130622
2018-01-01       179304
2019-01-01       171116
2020-01-01       184523

我们可以看到,2011 年和 2012 年的地震数量较其他年份少。2018 年记录数量显著增加,并在 2019 年和 2020 年保持增长。这似乎是不寻常的,我们可以假设地球突然变得更具地震活跃性,数据存在错误如记录重复,或者数据收集过程发生了变化。让我们进一步分析到月度水平,看看这一趋势是否在更详细的时间粒度上持续存在:

SELECT date_trunc('month',time)::date as earthquake_month
,count(*) as earthquakes
FROM earthquakes
GROUP BY 1
;

earthquake_month  earthquakes
----------------  -----------
2010-01-01        9651
2010-02-01        7697
2010-03-01        7750
...               ...

输出显示在图 6-10 中。我们可以看到,尽管地震数量每个月都有所变化,但从 2017 年开始似乎整体上有所增加。我们还可以看到,有三个异常月份,分别是 2010 年 4 月,2018 年 7 月和 2019 年 7 月。

图 6-10. 每月地震数量

从这里开始,我们可以继续检查更精细的时间段内的数据,也许可以选择性地通过一系列日期范围来过滤结果集,以便集中关注这些异常时间段。在缩小到特定的日期甚至每天的具体时间以确定何时发生尖峰后,我们可能希望进一步按数据集中的其他属性拆分数据。这可以帮助解释异常情况,或者至少缩小它们发生的条件范围。例如,结果表明,从 2017 年开始地震增加至少部分可以解释为status字段。状态指示事件是否已由人类审查(“reviewed”)或直接由系统发布而未经审查(“automatic”):

SELECT date_trunc('month',time)::date as earthquake_month
,status
,count(*) as earthquakes
FROM earthquakes
GROUP BY 1,2
ORDER BY 1
;

earthquake_month  status     earthquakes
----------------  --------   -----------
2010-01-01        automatic  620
2010-01-01        reviewed   9031
2010-02-01        automatic  695
...               ...        ...

“automatic”和“reviewed”状态的趋势在图 6-11 中绘制。

图 6-11. 每月地震数量,按状态分割

在图表中,我们可以看到 2018 年 7 月和 2019 年 7 月的异常计数是由于“automatic”状态地震数量大幅增加,而 2010 年 4 月的尖峰是在“reviewed”状态地震中发生的。可能在 2017 年添加了新型自动记录设备到数据集中,或者也许还没有足够时间审查所有记录。

分析具有该信息的数据集中的位置是发现和理解异常的另一种强大方式。earthquakes表包含有关成千上万的非常小地震的信息,可能会遮蔽我们对非常大、非常显著地震的视野。让我们看看震级大于或等于 6 级的大地震的位置分布:

SELECT place, count(*) as earthquakes
FROM earthquakes
WHERE mag >= 6
GROUP BY 1
ORDER BY 2 desc
;

place                                 earthquakes
------------------------------------  -----------
near the east coast of Honshu, Japan  52
off the east coast of Honshu, Japan   34
Vanuatu                               28
...                                   ...

与时间相比,在逐步更精细的级别进行查询的地方,place值已经如此精细化,以至于有些难以理解整体图景,尽管日本本州地区显然突出。我们可以应用来自第五章的一些文本分析技术来解析并分组地理信息。在这种情况下,我们将使用split_part来删除place字段开头经常出现的方向文字(如“靠近海岸”或“100km N of”):

SELECT 
case when place like '% of %' then split_part(place,' of ',2)
     else place
     end as place
,count(*) as earthquakes
FROM earthquakes
WHERE mag >= 6
GROUP BY 1
ORDER BY 2 desc
;

place                  earthquakes
---------------------  -----------
Honshu, Japan          89
Vanuatu                28
Lata, Solomon Islands  28
...                    ...

日本本州周围地区经历了 89 次地震,不仅是数据集中最大地震的发生地,而且是记录的大型地震数量的异常值。我们可以继续解析、清理和分组place值,以更精确地了解世界上发生重大地震的位置。

在数据中找到异常的计数、总和或频率通常需要一系列不同粒度查询的练习。通常从广泛的范围开始,然后更详细地查询,再次放大以与基线趋势进行比较,然后再次对数据的特定分割或维度进行详细查询是很常见的。幸运的是,SQL 是进行这种快速迭代的好工具。结合特别是来自时间序列分析的技术(如第三章中讨论的)和文本分析(如第五章中讨论的),将为分析带来更多丰富性。

数据缺失的异常

我们已经看到异常高频事件可以标志异常。请记住,记录缺失也可能标志异常。例如,手术中接受监控的病人的心跳。任何时候心跳缺失都会触发警报,就像心跳的不规则一样。然而,在许多情况下,如果您没有专门寻找它,检测数据缺失是困难的。客户并不总是宣布他们即将流失。他们只是停止使用产品或服务,然后悄悄地退出数据集。

确保数据中的缺失被注意到的一种方法是使用队列分析技术,如第四章中讨论的。特别是,JOIN 到日期系列或数据维度,确保每个实体都存在记录,无论其是否在该时间段内出现,这样可以更容易地检测到缺失。

检测缺失的另一种方法是查询间隙或距上次出现的时间。由于地球上构造板块的排列方式,一些地区更容易发生大地震。我们在先前的例子中也检测到了一些这种情况。即使我们知道它们可能发生的地方,地震通常很难预测。这并不能阻止一些人简单地因为距离上次大地震的时间过长而猜测下一次“大地震”。我们可以使用 SQL 来查找大地震之间的间隙和自上次大地震以来的时间:

SELECT place
,extract('days' from '2020-12-31 23:59:59' - latest) 
 as days_since_latest
,count(*) as earthquakes
,extract('days' from avg(gap)) as avg_gap
,extract('days' from max(gap)) as max_gap
FROM
(
    SELECT place
    ,time
    ,lead(time) over (partition by place order by time) as next_time
    ,lead(time) over (partition by place order by time) - time as gap
    ,max(time) over (partition by place) as latest
    FROM
    (
        SELECT 
        replace(
          initcap(
          case when place ~ ', [A-Z]' then split_part(place,', ',2)
               when place like '% of %' then split_part(place,' of ',2)
               else place end
        )
        ,'Region','')
        as place
        ,time
        FROM earthquakes
        WHERE mag > 5
    ) a
) a         
GROUP BY 1,2        
;

place             days_since_latest  earthquakes  avg_gap  max_gap
----------------  -----------------  -----------  -------  -------
Greece            62.0               109          36.0     256.0
Nevada            30.0               9            355.0    1234.0
Falkland Islands  2593.0             3            0.0      0.0
...               ...                ...          ...      ...

在最内层子查询中,place字段被解析和清理,返回更大的地区或国家,以及每次 5 级或更大地震的时间。第二个子查询使用lead函数查找每个地点和时间的下一个地震的time,以及每次地震与下一个地震之间的gapmax窗口函数返回每个地点的最近地震。外部查询使用extract函数计算数据集中距离最新的 5 级以上地震的天数,只返回两个日期相减后的间隔中的天数。由于数据集仅包括到 2020 年底的记录,因此时间戳“2020-12-31 23:59:59”被使用,尽管如果数据定期刷新,current_timestamp或等效表达式也是合适的。类似地,从gap值的平均值和最大值中提取天数。

一个地点自上次大地震以来的时间可能在实践中没有多少预测力,但在许多领域,间隔和自上次出现以来的时间度量具有实际应用。了解动作之间的典型间隔可以建立一个基准,用来比较当前的间隔。当当前间隔在历史值的范围内时,我们可能会判断客户已经保持,但是当当前间隔较长时,流失的风险就增加了。从返回历史间隔的查询结果集本身可以成为异常检测分析的主题,回答诸如客户在离开后多长时间才返回的最长时间等问题。

处理异常

异常可能出现在数据集中的许多原因和形式,正如我们刚刚看到的。检测到异常后,下一步是以某种方式处理它们。如何处理取决于异常的来源——潜在过程或数据质量问题——以及数据集或分析的最终目标。选项包括进行调查而不进行更改、删除、替换、重新缩放和上游修复。

调查

发现或试图找出异常的原因通常是决定如何处理它的第一步。这一过程既有趣又令人沮丧——有趣的是追踪和解决谜团激发了我们的技能和创造力,但令人沮丧的是我们经常在时间紧迫的情况下工作,追踪异常就像是进入一个无尽的兔子洞,使我们怀疑整个分析是否存在缺陷。

当我调查异常时,我的过程通常涉及一系列查询,这些查询在搜索模式和查看特定示例之间来回跳转。真正的异常值很容易辨认。在这种情况下,我通常会查询包含异常值的整行,以获取有关时间、来源和其他可用属性的线索。接下来,我会检查具有相同属性的记录,看看它们的值是否看起来不寻常。例如,我可能会检查同一天的其他记录是否具有正常或异常值。来自特定网站的流量或特定产品的购买可能会显示其他异常情况。

在调查内部组织生成的数据的异常源和属性后,我会与利益相关者或产品所有者联系。有时会出现已知的错误或缺陷,但通常会存在需要解决的真正问题或过程或系统中的问题,并且上下文信息很有用。对于外部或公共数据集,可能无法找到根本原因。在这些情况下,我的目标是收集足够的信息,以决定下一步讨论的选项中哪一种适合。

删除

处理数据异常的一个选项是简单地从数据集中删除它们。如果有理由怀疑数据收集中存在可能影响整个记录的错误,那么删除就是合适的。当数据集足够大以至于删除几条记录不太可能影响结论时,删除也是一个好选择。使用删除的另一个好理由是,当异常值非常极端以至于会使结果偏离到完全不适当的结论时。

我们之前看到 earthquakes 数据集包含许多震级为 –9.99 和少数为 –9 的记录。由于这些值对应的地震非常小,我们可能会怀疑它们是错误值或者仅在实际震级未知时输入。在 WHERE 子句中,删除具有这些值的记录非常简单:

SELECT time, mag, type
FROM earthquakes
WHERE mag not in (-9,-9.99)
limit 100
;

time                 mag   type
-------------------  ----  ----------
2019-08-11 03:29:20  4.3   earthquake
2019-08-11 03:27:19  0.32  earthquake
2019-08-11 03:25:39  1.8   earthquake

然而,在删除记录之前,我们可能需要确定包含异常值是否真的会影响输出结果。例如,我们可能想知道删除异常值是否会影响平均幅度,因为异常值很容易使平均值偏离。我们可以通过计算整个数据集的平均值以及使用 CASE 语句来排除极端低值的平均值来进行这项工作:

SELECT avg(mag) as avg_mag
,avg(case when mag > -9 then mag end) as avg_mag_adjusted
FROM earthquakes
;

avg_mag               avg_mag_adjusted
------------------    ------------------
1.6251015161530643    1.6273225642983641

这些平均值仅在第三个有效数字处有所不同(1.625 对比 1.627),这是一个相当小的差异。然而,如果我们仅筛选黄石国家公园,那里有许多值为 –9.99 的记录时,差异就更加显著:

SELECT avg(mag) as avg_mag
,avg(case when mag > -9 then mag end) as avg_mag_adjusted
FROM earthquakes
WHERE place = 'Yellowstone National Park, Wyoming'
;

avg_mag                 avg_mag_adjusted
----------------------  ----------------------
0.40639347873981053095  0.92332793709528214616

尽管这些值仍然很小,0.46 和 0.92 的平均差异足够大,我们很可能选择删除异常值。

请注意,有两种处理方式:一种是在WHERE子句中,从所有结果中移除异常值;另一种是在 CASE 语句中,仅从特定计算中移除异常值。选择哪种方式取决于分析的上下文以及是否重要保留行以保留总计数或其他字段中的有用值。

替换为备用值

异常值通常可以通过替换为其他值而不是移除整个记录来处理。备用值可以是默认值、替代值、范围内最接近的数值或诸如平均值或中位数的汇总统计量。

我们之前看到,可以使用coalesce函数将空值替换为默认值。当值不一定为空但由于其他原因有问题时,可以使用 CASE 语句将默认值替换。例如,我们可能希望将地震的类型分组为单个“其他”值:

SELECT 
case when type = 'earthquake' then type
     else 'Other'
     end as event_type
,count(*)
FROM earthquakes
GROUP BY 1
;

event_type  count
----------  -------
earthquake  1461750
Other       34176

当然,这会减少数据中的细节,但也可以是汇总具有多个type异常值的数据集的一种方法,正如我们之前看到的。当您知道异常值是不正确的,并且您知道正确的值时,使用 CASE 语句替换它们也是一种保留整体数据集中行的解决方案。例如,可能在记录的末尾添加了额外的 0,或者以英寸而不是英里记录了一个值。

处理数值异常值的另一种选项是用最接近的高值或低值替换极端值。这种方法保持了大部分值范围,但防止了由极端异常值导致的误导性平均值。Winsorization是一种特定的技术,其中异常值被设置为数据的特定百分位。例如,将超过第 95 百分位的值设置为第 95 百分位的值,将低于第 5 百分位的值设置为第 5 百分位的值。要在 SQL 中计算这一点,我们首先计算第 5 和第 95 百分位值:

SELECT percentile_cont(0.95) within group (order by mag) 
 as percentile_95
,percentile_cont(0.05) within group (order by mag) 
 as percentile_05
FROM earthquakes
;

percentile_95  percentile_05
-------------  -------------
4.5            0.12

我们可以将此计算放在子查询中,然后使用 CASE 语句来处理将异常值设置为第 5 百分位以下和第 95 百分位以上的值。请注意,笛卡尔JOIN允许我们将百分位值与每个单独的幅度进行比较:

SELECT a.time, a.place, a.mag
,case when a.mag > b.percentile_95 then b.percentile_95
      when a.mag < b.percentile_05 then b.percentile_05
      else a.mag
      end as mag_winsorized
FROM earthquakes a
JOIN
(
    SELECT percentile_cont(0.95) within group (order by mag) 
     as percentile_95
    ,percentile_cont(0.05) within group (order by mag) 
     as percentile_05
    FROM earthquakes
) b on 1 = 1 
;

time                 place                        mag   mag_winsorize
-------------------  ---------------------------  ----  -------------
2014-01-19 06:31:50  5 km SW of Volcano, Hawaii   -9    0.12
2012-06-11 01:59:01  Nevada                       -2.6  0.12
...                  ...                          ...   ...
2020-01-27 21:59:01  31km WNW of Alamo, Nevada    2     2.0
2013-07-07 08:38:59  54km S of Fredonia, Arizona  3.5   3.5
...                  ...                          ...   ...
2013-09-25 16:42:43  46km SSE of Acari, Peru      7.1   4.5
2015-04-25 06:11:25  36km E of Khudi, Nepal       7.8   4.5
...                  ...                          ...   ...

第 5 百分位值为 0.12,而第 95 百分位为 4.5。低于这些阈值和高于这些阈值的值将更改为mag_winsorize字段中的阈值。在这些阈值之间的值保持不变。对于 winsorizing 没有设定百分位阈值。分析要求和异常值的普遍性和极端程度将根据需要使用第 1 和 99 百分位或甚至第 0.01 和 99.9 百分位。

重新缩放

而不是过滤记录或更改异常值的值,重新缩放值提供了一条路径,保留所有值但使分析和绘图更容易。

我们之前讨论过 z 分数,但值得指出的是,这可以用作重新缩放值的一种方式。z 分数很有用,因为它既可以用于正数也可以用于负数。

另一种常见的变换是转换为对数(log)比例。将值转换为对数比例的好处在于它们保持相同的顺序,但小数字会更加分散。对数变换也可以转换回原始比例,便于解释。缺点是对数变换不能用于负数。在earthquakes数据集中,我们了解到震级已经用对数比例表示。9.1 级的东北大地震是极端的,但如果不用对数比例表示,该值看起来会更极端!

depth字段以公里为单位。在这里,我们将查询深度和应用log函数的深度,然后将输出绘制在图 6-12 和 6-13 中,以展示差异。log函数默认使用 10 作为底数。为了减少结果集以便更轻松地绘图,深度还使用round函数将其四舍五入到一位有效数字。表被过滤以排除小于 0.05 的值,因为这些值会四舍五入为零或小于零:

SELECT round(depth,1) as depth
,log(round(depth,1)) as log_depth
,count(*) as earthquakes
FROM earthquakes
WHERE depth >= 0.05
GROUP BY 1,2
;

depth  log_depth            earthquakes
-----  -------------------  -----------
0.1    -1.0000000000000000  6994
0.2    -0.6989700043360188  6876
0.3    -0.5228787452803376  7269
...    ...                  ...

图 6-12。地震深度分布,未调整深度

图 6-13。地震深度的对数比例分布

在图 6-12 中,显然有大量的地震在 0.05 到 20 之间,但超过这个范围的分布难以看清楚,因为 x 轴延伸到 700 以捕捉数据的范围。然而,当深度转换为对数比例时,在图 6-13 中,较小值的分布要容易得多。值得注意的是,高于 1.0 的尖峰对应深度 10 公里时是明显的。

小贴士

其他类型的比例变换,虽然不一定适合去除异常值,但可以通过 SQL 完成。一些常见的包括:

  • 平方根:使用sqrt函数

  • 立方根:使用cbrt函数

  • 倒数变换:1 / field_name

更改单位,例如将英寸转换为英尺或磅转换为千克:用*或/乘以或除以适当的转换因子。

可以在 SQL 代码中进行重新缩放,或者通常在用于绘图的软件或编程语言中进行。对数变换在存在大量正值的情况下特别有用,并且重要的检测模式存在于较低值中时。

与所有分析一样,决定如何处理异常取决于目的以及您对数据集的上下文或领域知识量。删除异常值是最简单的方法,但为了保留所有记录,诸如 winsorizing 和重新调整的技术也很有效。

结论

异常检测在分析中是一种常见的实践。目标可能是检测异常值,也可能是操作这些异常值,以便为进一步分析准备数据集。在任何情况下,排序、计算百分位数以及绘制 SQL 查询输出的基本工具可以帮助您有效地找到它们。异常情况有多种类型,包括异常值、异常活动突发以及异常缺失,这些是最常见的。领域知识几乎总是在您进行发现和收集有关异常原因的信息过程中有所帮助。处理异常的选项包括调查、移除、替换为替代值和重新调整数据。选择取决于目标,但这些路径都可以通过 SQL 实现。在下一章中,我们将把注意力转向实验,目标是弄清整个受试者组是否与对照组的标准不同。

¹ https://www.mathsisfun.com/data/standard-deviation-formulas.html 有一个很好的解释。

第七章:实验分析

实验,也称为A/B 测试分割测试,被认为是建立因果关系的黄金标准。许多数据分析工作涉及建立相关性:一件事在另一件事发生时更有可能发生,无论是行动、属性还是季节模式。您可能听说过“相关不意味着因果关系”的说法,而正是数据分析中的这个问题,实验试图解决。

所有实验都始于一个假设:关于产品、流程或消息进行某种改变会导致行为变化的猜测。这种改变可能是用户界面、新用户入门流程、推荐算法、营销消息或时机等任何领域。理论上,只要组织构建或控制它,就可以进行实验。假设通常受到其他数据分析工作的驱动。例如,我们可能发现很高比例的人在结账流程中退出,我们可以假设如果减少步骤,更多人可能会完成结账流程。

任何实验所需的第二个要素是成功指标。我们假设的行为变化可能与表单完成、购买转化、点击率、保留率、参与度或组织使命相关的任何其他行为有关。成功指标应量化这种行为,易于测量,并且足够敏感以检测到变化。点击率、结账完成率和完成流程所需时间通常是很好的成功指标。保留率和客户满意度通常不太适合作为成功指标,尽管它们非常重要,因为它们往往受到超出任何个别实验所测试内容的许多因素的影响,因此对我们想测试的变化不太敏感。好的成功指标通常是您已经作为了解公司或组织健康的一部分而跟踪的指标。

提示

你可能会想知道一个实验是否可以有多个成功指标。当然,使用 SQL,通常可以生成许多不同的计算和指标。但是,你应该意识到存在多重比较问题。我不会在这里进行详细解释,但核心是,你查看的地方越多,找到显著变化的可能性就越大。检查一个指标,你可能会发现实验变体中的一个是否有显著变化,也可能没有。然而,如果检查 20 个指标,至少有一个显示显著性的可能性相当大,无论实验是否与该指标有关。作为一个经验法则,应该有一个或者最多两个主要的成功指标。还可以使用一到五个额外的指标用于防范风险,有时被称为防护指标。例如,你可能希望确保实验不会影响页面加载时间,即使这并不是实验的目标所在。

实验的第三要素是一个随机将实体分配到控制组或实验变体组,并相应地修改体验的系统。这种类型的系统有时也被称为分组系统。许多软件供应商提供实验分组工具,尽管一些组织选择内部构建它们以获得更大的灵活性。无论哪种方式,要使用 SQL 进行实验分析,实体级别的分配数据必须流入数据库表中,同时包含行为数据。

小贴士

本章讨论的实验特指在线实验,在这些实验中,变体分配通过计算机系统完成,并且行为被数字化跟踪。当然,科学和社会科学领域进行许多类型的实验。一个关键的区别是,在在线实验中检查的成功指标和行为通常已经被用于其他目的进行跟踪,而在许多科学研究中,结果行为是专门为实验跟踪的,并且仅在实验期间跟踪。在在线实验中,有时需要创造性地寻找能代表影响的好指标,当直接测量不可行时。

有了假设、成功指标和变体分组系统,你可以运行实验,收集数据,并使用 SQL 分析结果。

SQL 实验分析的优势和限制

SQL 对于分析实验非常有用。在许多情况下,实验队列数据和行为数据已经流入数据库,使得 SQL 成为一个自然的选择。成功指标通常已经成为组织报告和分析词汇的一部分,并且已经开发了 SQL 查询。将变体分配数据加入现有的查询逻辑通常相对比较直接。

SQL 是自动化实验结果报告的一个很好的选择。可以为每个实验运行相同的查询,在 WHERE 子句中替换实验的名称或标识符。许多进行大量实验的组织已创建了标准化报告,以加快阅读速度并简化解释过程。

尽管 SQL 在实验分析的许多步骤中很有用,但它确实有一个主要缺点:SQL 无法计算统计显著性。许多数据库允许开发人员通过 用户定义函数(UDFs)扩展 SQL 功能。UDFs 可能能够利用来自诸如 Python 等语言的统计测试,但这超出了本书的范围。一个很好的选择是在 SQL 中计算摘要统计信息,然后使用提供在Evanmiller.org的在线计算器来确定实验结果是否具有统计显著性。

数据集

本章节中,我们将使用来自虚构的 Tanimura Studios 移动游戏的数据集。有四个表格。game_users 表包含下载移动游戏的人员记录,以及日期和国家。数据样本见图 7-1。

图 7-1. game_users 表的样本

game_actions 表包含用户在游戏中的操作记录。数据样本见图 7-2。

图 7-2. game_actions 表的样本

game_purchases 表跟踪以美元计的游戏内货币购买记录。数据样本见图 7-3。

图 7-3. game_purchases 表的样本

最后,exp_assignment 表包含用户分配给特定实验变体的记录。数据样本见图 7-4。

图 7-4. exp_assignment 表的样本

所有这些表中的数据都是虚构的,使用随机数生成器创建的,尽管结构类似于您可能在真实数字游戏公司的数据库中看到的。

实验类型

实验种类繁多。如果可以改变用户、客户、选民或其他实体体验的某些方面,理论上可以测试该变更。从分析角度来看,实验主要分为两种类型:二元结果和连续结果。

二元结果的实验:卡方检验

如您所预期的,二元结果实验只有两种结果:要么采取行动,要么不采取。用户完成注册流程或不完成。消费者点击网站广告或不点击。学生毕业或不毕业。对于这些类型的实验,我们计算每个变体完成操作的比例。分子是完成者的数量,分母是所有曝光单元。该指标也称为比率:完成率、点击率、毕业率等。

要确定变体中的比率是否在统计上存在差异,我们可以使用卡方检验,这是一种用于分类变量的统计检验。¹ 卡方检验的数据通常以列联表的形式显示,该表显示了两个属性交集处的观察频率。对于熟悉这种类型表格的人来说,这看起来就像是透视表。

让我们看一个例子,使用我们的移动游戏数据集。产品经理推出了新版本的入职流程,一系列屏幕向新玩家介绍游戏的工作原理。产品经理希望新版本能够增加完成入职并开始他们的第一游戏会话的玩家数量。新版本在名为“Onboarding”的实验中引入,将用户分配到控制组或变体 1,如exp_assignment表中跟踪的那样。game_actions表中的“完成入职”事件指示用户是否完成了入职流程。

列联表显示了变体分配(控制或变体 1)与是否完成入职的交集处的频率。我们可以使用查询来找到表的值。在这里,我们计算具有和没有“完成入职”动作的用户数,并按variant进行GROUP BY

SELECT a.variant
,count(case when b.user_id is not null then a.user_id end) as completed
,count(case when b.user_id is null then a.user_id end) as not_completed
FROM exp_assignment a
LEFT JOIN game_actions b on a.user_id = b.user_id
 and b.action = 'onboarding complete'
WHERE a.exp_name = 'Onboarding'
GROUP BY 1
;

variant    completed  not_completed
---------  ---------  -------------
control    36268      13629
variant 1  38280      11995

将每行和每列的总数加起来,将此输出转换为一个列联表,如图 7-5 所示。

图 7-5. 完成入职的列联表

要使用在线显著性计算器之一,我们需要成功次数或执行动作的次数以及每个变体的总体配对数。查找所需数据点的 SQL 非常简单。从exp_assignment表中查询分配的变体和该变体的用户count。然后,我们LEFT JOIN game_actions表以查找完成入职的用户count。由于我们预期并非所有用户都完成了相关操作,因此需要LEFT JOIN。最后,通过将完成用户数除以总配对数来查找每个变体的完成百分比:

SELECT a.variant
,count(a.user_id) as total_cohorted
,count(b.user_id) as completions
,count(b.user_id) / count(a.user_id) as pct_completed
FROM exp_assignment a
LEFT JOIN game_actions b on a.user_id = b.user_id
 and b.action = 'onboarding complete'
WHERE a.exp_name = 'Onboarding'
GROUP BY 1
;

variant    total_cohorted  completions  pct_completed
---------  --------------  -----------  -------------
control    49897           36268        0.7269
variant 1  50275           38280        0.7614

我们可以看到,变体 1 确实比控制体验有更多的完成率,完成率为 76.14%,而控制体验为 72.69%。但这种差异在统计学上是否显著,允许我们拒绝无差异的假设呢?为此,我们将结果输入在线计算器,并确认在 95%的置信水平下,变体 1 的完成率显著高于控制组。变体 1 可以被宣布为胜利者。

提示

常用的置信水平是 95%,尽管这不是唯一的选择。关于置信水平含义、使用哪个水平以及在比较多个变体与控制组时的调整,有许多在线文章和讨论。

二进制结果实验遵循这个基本模式。计算每个变体中的成功或完成次数,以及每个变体中的总成员数。用于获取成功事件的 SQL 可能会更加复杂,这取决于表格和动作在数据库中的存储方式,但输出是一致的。接下来,我们将转向具有连续结果的实验。

连续结果实验:t 检验

许多实验旨在改进连续指标,而不是上一节讨论的二进制结果。连续指标可以取多个值。例如,客户的消费金额、页面停留时间以及应用程序使用天数。电子商务网站通常希望增加销售额,因此它们可能会在产品页面或结账流程上进行实验。内容网站可能会测试布局、导航和标题,以增加阅读的文章数量。运行应用程序的公司可能会进行重新营销活动,提醒用户回到应用程序。

对于这些以及其他具有连续成功指标的实验,目标是找出每个变体中的平均值是否在统计上有显著差异。相关的统计检验是双样本 t 检验,它确定在通常为 95%的置信区间下,我们是否可以拒绝平均值相等的零假设。统计测试有三个输入,所有这些都可以通过 SQL 进行简单计算:平均值、标准差和观察数量。

让我们通过游戏数据来看一个例子。在上一节中,我们考察了新的入职流程是否提高了完成率。现在我们将考虑这个新流程是否增加了用户在游戏中的货币支出。成功指标是支出金额,因此我们需要计算每个变体的平均值和标准差。首先,我们需要计算每位用户的金额,因为用户可以进行多次购买。从exp_assignment表中检索分组分配,并计数用户。接下来,LEFT JOINgame_purchases表中获取金额数据。由于不是所有用户都会购买,所以需要LEFT JOIN,但我们仍然需要包括他们在平均值和标准差计算中。对于没有购买记录的用户,金额默认设置为 0,使用coalesce函数确保这些记录被包括在内,因为avgstddev函数会忽略空值。外部查询通过变体对输出值进行汇总:

SELECT variant
,count(user_id) as total_cohorted
,avg(amount) as mean_amount
,stddev(amount) as stddev_amount
FROM
(
    SELECT a.variant
    ,a.user_id
    ,sum(coalesce(b.amount,0)) as amount
    FROM exp_assignment a
    LEFT JOIN game_purchases b on a.user_id = b.user_id
    WHERE a.exp_name = 'Onboarding'
    GROUP BY 1,2
) a
GROUP BY 1
;

variant    total_cohorted  mean_amount  stddev_amount
---------  --------------  -----------  -------------
control    49897           3.781        18.940
variant 1  50275           3.688        19.220

接下来,我们将这些数值输入在线计算器中,并发现在 95%置信区间内,控制组和变体组之间没有显著差异。 “变体 1”组的入职完成率似乎有所提高,但未增加金额支出。

另一个我们可以考虑的问题是变体 1 是否影响了那些完成了入职流程的用户的支出。那些未完成入职流程的用户永远无法进入游戏,因此甚至没有机会进行购买。为了回答这个问题,我们可以使用类似于之前的查询,但是我们将向game_actions表添加一个INNER JOIN,以限制计算的用户仅限于具有“入职完成”操作的用户:

SELECT variant
,count(user_id) as total_cohorted
,avg(amount) as mean_amount
,stddev(amount) as stddev_amount
FROM
(
    SELECT a.variant
    ,a.user_id
    ,sum(coalesce(b.amount,0)) as amount
    FROM exp_assignment a
    LEFT JOIN game_purchases b on a.user_id = b.user_id
    JOIN game_actions c on a.user_id = c.user_id
     and c.action = 'onboarding complete'
    WHERE a.exp_name = 'Onboarding'
    GROUP BY 1,2
) a
GROUP BY 1
;

variant    total_cohorted  mean_amount  stddev_amount
---------  --------------  -----------  -------------
control    36268           5.202        22.049
variant 1  38280           4.843        21.899

将这些数值输入计算器后,发现控制组的平均值在 95%置信区间内显著高于变体 1 组。这个结果可能看似令人困惑,但说明了为什么事先在实验中达成对成功指标的共识是如此重要。实验变体 1 对入职完成率有积极影响,因此可以判定为成功。但它对整体支出水平没有影响。这可能是由于混合变化导致的:在变体 1 中完成入职的额外用户较不可能付款。如果基础假设是增加入职完成率将增加收入,那么实验不应被视为成功,产品经理应该提出一些新的测试想法。

实验中的挑战及修复失败实验的选项

虽然实验是理解因果关系的黄金标准,但实验可能出现多种问题。如果整个前提是错误的,SQL 也无法拯救。如果问题更多是技术性质的,我们可以通过查询数据的方式调整或排除有问题的数据点,仍然可以解释一些结果。进行实验会消耗工程师、设计师或营销人员创建变体的时间成本。此外还有机会成本,即错失的本可以通过将客户引导至最佳转化路径或产品体验而获得的好处。在实际操作层面上,使用 SQL 至少能让组织从实验中学到一些东西,通常是值得花费时间的。

变体分配

随机分配实验单元(可以是用户、会话或其他实体)到控制组和变体组是实验的关键要素之一。然而,有时分配过程中会出现错误,无论是因为实验规范的缺陷、技术故障还是分组软件的限制。结果是,控制组和变体组可能大小不等,可能比预期少了一些实体被分组,或者实际分配可能并非完全随机。

SQL 有时可以帮助挽救那些因分组过多而导致实验失败的情况。我曾见过这种情况发生在实验本应只针对新用户,但所有用户都被分组的情况下。另一种情况是当实验测试仅有部分用户能看到的内容时发生,无论是因为需要点击几次才能看到,还是因为需要满足特定条件,比如之前的购买记录。由于技术限制,所有用户都被分组,即使其中很多人永远也看不到实验的内容。解决方案是通过在 SQL 中添加JOIN来限制只考虑原本应符合条件的用户或实体。例如,在新用户实验中,我们可以添加一个INNER JOIN到包含用户注册日期的表或子查询,并设置一个WHERE条件来排除那些注册时间过早的用户。当需要满足特定条件才能看到实验时,也可以使用相同的策略。通过JOINWHERE条件来限制包含的实体,排除那些不应该符合条件的。做完这些操作后,应确保结果样本足够大以产生显著的结果。

如果分组的用户或实体太少,重要的是检查样本是否足够大以产生显著结果。如果不够大,则需要重新运行实验。如果样本量足够大,第二个考虑因素是分组是否存在偏差。例如,我曾经看到某些浏览器或旧版本应用由于技术限制而未被分组的情况。如果被排除的群体不是随机的,并且代表了位置、技术能力或社会经济地位的差异,重要的是考虑这些群体相对于其他群体的规模以及是否应该调整以将其纳入最终分析中。

另一个可能性是变体分配系统存在缺陷,实体并未随机分配。这在大多数现代实验工具中相当不常见,但如果发生这种情况,则会使整个实验无效。结果“太好以至于难以置信”可能表明存在变体分配问题。例如,我曾经看到过高度参与的用户由于实验配置更改而意外地分配到了治疗组和对照组。通过仔细的数据分析可以检查实体是否已分配到多个变体,以及在实验之前是否存在高或低参与度的用户聚集在特定变体中。

提示

运行 A/A 测试可以帮助发现变体分配软件中的缺陷。在这种测试中,实体被分组,并且成功指标进行比较,就像任何其他实验一样。然而,体验不会发生任何变化,两个分组都接收到控制体验。由于两组接收到相同的体验,我们不应该期望成功指标有显著差异。如果确实存在差异,需要进一步调查并解决问题。

异常值

用于分析连续成功指标的统计测试依赖于平均值。因此,它们对异常高或低的异常值非常敏感。我曾经看到实验中的一些情况,其中一个或两个特别高消费的客户的存在使得一个变体比其他变体具有统计显著优势。如果没有这些高消费者,结果可能是中性甚至相反的。在大多数情况下,我们更关心的是一种治疗是否对各种个体产生效果,因此调整这些异常值可以使实验结果更具有意义。

我们在第六章中讨论了异常检测,实验分析是这些技术可以应用的另一个领域。异常值可以通过分析实验结果或找到实验之前的基本率来确定。异常值可以通过例如 winso 化(也在第六章中讨论过)的技术移除,该技术移除超出阈值的值,例如 95th 或 99th 百分位数。这可以在进入实验分析的 SQL 之前完成。

处理连续成功指标中的异常值的另一种选择是将成功指标转换为二进制结果。例如,不再比较对照组和变体组的平均花费,因为这可能因为少数非常高的花费者而扭曲,而是比较两组之间的购买率,然后按照讨论的在二进制结果实验部分的程序进行。我们可以考虑在“入门”实验中完成入门的用户中的购买转化率,对比对照组和变体 1 组:

SELECT a.variant
,count(distinct a.user_id) as total_cohorted
,count(distinct b.user_id) as purchasers
,count(distinct b.user_id) / count(distinct a.user_id) 
 as pct_purchased
FROM exp_assignment a
LEFT JOIN game_purchases b on a.user_id = b.user_id
JOIN game_actions c on a.user_id = c.user_id
 and c.action = 'onboarding complete'
WHERE a.exp_name = 'Onboarding'
GROUP BY 1
;

variant    total_cohorted  purchasers  pct_purchased
---------  --------------  ----------  -------------
control    36268           4988        0.1000
variant 1  38280           4981        0.0991

我们可以观察到,尽管第一个变体的用户更多,但购买者却更少。在对照组中,购买用户的比例为 10%,而第一个变体为 9.91%。接下来,我们将数据点输入在线计算器。在对照组中,转化率在统计上显著更高。在这种情况下,尽管对照组的购买率更高,但从实际角度来看,如果我们认为更多用户完成入门流程具有其他益处,我们可能愿意接受这种小幅下降。例如,更多的玩家可能会提升排名,而享受游戏的玩家可能会通过口口相传将游戏传播给朋友,这两者都有助于增长,进而可能吸引其他新玩家成为购买者。

成功指标也可以设置为阈值,并比较达到该阈值的实体的份额。例如,成功指标可以是阅读至少三篇故事或每周使用应用程序至少两次。可以以这种方式构建无数的指标,因此了解对组织而言既重要又有意义的内容至关重要。

时间盒定

实验通常持续几周。这意味着较早进入实验的个体有更长的时间窗口来完成与成功指标相关的操作。为了控制这一点,我们可以应用时间盒定——根据实验进入日期固定时间长度,并仅考虑该时间窗口内的操作。这个概念也在第四章中有所涵盖。

对于实验来说,时间框的合适大小取决于你要测量的内容。当测量通常具有即时响应的行为时,窗口可能只有一小时。对于购买转化,实验者通常允许 1 至 7 天的时间窗口。较短的窗口可以更早地分析实验结果,因为所有的分组实体都需要允许完全时间来完成行动。最佳的窗口平衡了获取结果的需求与组织的实际动态。如果客户通常在几天内转化,请考虑 7 天的窗口;如果客户通常需要 20 天或更长时间,请考虑 30 天的窗口。

例如,我们可以通过仅包括与集团事件发生后 7 天内的购买来修订我们从连续结果实验中的第一个例子。注意,使用实体分配给变体的时间作为时间框的起始点非常重要。添加了额外的ON子句,限制结果仅限于在“7 天”间内发生的购买:

SELECT variant
,count(user_id) as total_cohorted
,avg(amount) as mean_amount
,stddev(amount) as stddev_amount
FROM
(
    SELECT a.variant
    ,a.user_id
    ,sum(coalesce(b.amount,0)) as amount
    FROM exp_assignment a
    LEFT JOIN game_purchases b on a.user_id = b.user_id 
     and b.purch_date <= a.exp_date + interval '7 days'
    WHERE a.exp_name = 'Onboarding'
    GROUP BY 1,2
) a
GROUP BY 1
;

variant    total_cohorted  mean_amount  stddev_amount
---------  --------------  -----------  -------------
control    49897           1.369        5.766
variant 1  50275           1.352        5.613

这些平均数相似,事实上在统计上它们彼此之间没有显著差异。在这个例子中,时间框结论与没有时间框时的结论一致。

在这种情况下,购买事件相对较少。对于衡量常见事件和快速累积的指标,如页面浏览量、点击量、点赞和阅读文章,使用时间框可以防止最早分组的用户看起来比后来的分组用户显著“更好”。

重复接触实验

在在线实验讨论中,大多数例子都是我喜欢称之为“一劳永逸”的体验:用户只遇到一次处理,对此做出反应,然后不再经过。用户注册是一个典型的例子:消费者仅注册某项服务一次,因此对注册流程的任何更改仅影响新用户。分析这些体验上的测试相对比较简单。

另一种经验类型被我称为“重复接触”,在这种情况下,个体在使用产品或服务的过程中多次接触到变化。在涉及这些变化的任何实验中,我们可以预期个体会多次遇到它们。应用程序用户界面的变化,如颜色、文本和重要信息及链接的位置,在用户使用应用程序期间会被用户体验到。定期向客户发送提醒或促销的电子邮件营销计划也具有这种重复接触的特质。电子邮件在收件箱中作为主题行出现多次,并且如果被打开,作为内容出现。

测量重复曝光实验比测量一次性实验更加棘手,因为存在新奇效应和均值回归现象。新奇效应是行为因为某事物新而改变的倾向,并非因为它实际上更好。均值回归是现象随着时间返回到平均水平的倾向。例如,改变用户界面的任何部分都倾向于增加与其互动的人数,无论是新的按钮颜色、标志还是功能放置位置。最初指标看起来不错,因为点击率或参与度上升了。这就是新奇效应。但随着时间的推移,用户习惯于这种变化,他们倾向于以接近基线的速率点击或使用功能。这就是均值回归。运行这类实验时要回答的重要问题是新基线是否高于(或低于)之前的基线。一种解决方案是允许足够长的时间段过去,在这段时间内您可能期望发生均值回归,然后再评估结果。在某些情况下,这可能是几天;在其他情况下,可能是几周或几个月。

当变化很多或实验以系列形式出现(如电子邮件或实体邮件活动)时,弄清整个方案是否有所不同可能是个挑战。当顾客收到某种电子邮件变体后购买产品时,很容易宣称成功,但我们如何知道他们是否本来就会做出那个购买决定呢?一种选择是建立长期对照组。这是一个不接收任何营销消息或产品体验变更的群体。请注意,这与简单比较选择退出营销消息的用户不同,因为通常存在一些选择退出和不选择退出的偏见。设置长期对照组可能会很复杂,但几乎没有更好的方法来真正衡量活动和产品变更的累积效果。

另一种选择是对变体进行队列分析(详见第四章)。可以在较长时间段内追踪这些群体,从几周到几个月不等。可以计算和测试留存或累积指标,以查看长期内变体之间的效果是否有所不同。

尽管在实验中可能会遇到各种挑战,但它们仍然是测试和验证涉及从营销消息和创意到产品体验中所做更改因果关系的最佳方式。我们在数据分析中经常遇到不太理想的情况,因此接下来我们将转向一些分析选项,以解决无法进行 A/B 测试时的情况。

当无法进行控制实验时:替代分析方法

随机实验是超越相关性以建立因果关系的黄金标准。然而,有许多原因可能导致无法进行随机实验。在医疗或教育环境中,给予不同群体不同治疗可能是不道德的。监管要求可能会阻止在其他环境中进行实验,如金融服务。还可能存在实际原因,例如难以限制变异处理仅限于随机群体的访问。始终值得考虑是否有值得在伦理、监管和实际边界内测试或可测试的部分。措辞、位置和其他设计元素是一些示例。

第二种无法进行实验的情况是当变化已经发生且数据已经收集时。除了恢复变化之外,回去重新进行实验不是一个选项。有时数据分析师或数据科学家无法提供关于实验的建议。我曾多次加入一个组织,并被要求解开那些本来如果有对照组会更容易理解的变化结果。在其他情况下,变化是意外的。例如影响部分或全部客户的站点停机、表单错误以及如风暴、地震和山火等自然灾害。

虽然在没有实验的情况下,因果推断的结论不如强有力,但有一些准实验分析方法可以从数据中获得洞见。这些方法依赖于从可用数据中构建尽可能接近的“对照”和“治疗”条件的组。

预/后分析

预/后分析比较同一或相似人群在变化前后的情况。变化前的人群测量用作对照,而变化后的人群测量则用作变量或治疗组。

预/后分析在有清晰定义的变化发生在已知日期的情况下效果最好,这样可以清晰地划分出变化前后的群体。在这种分析类型中,您需要选择在变化前后测量多长时间,但这些期间应该是相等或接近相等的。例如,如果变化发生后已经过去了两周,那么将这段时间与变化前的两周进行比较。考虑比较多个时期,如变化前后一周、两周、三周和四周。如果所有这些时间段的结果都一致,那么您对结果的信心就会比结果不一致时更高。

让我们通过一个例子来详细说明。想象一下,我们的移动游戏的入职流程包括一个步骤,用户可以勾选一个框,表示他们是否希望接收游戏新闻的电子邮件。这一直以来都是默认选中的,但是一项新的法规要求现在默认不选中。2020 年 1 月 27 日,这一变更发布到游戏中,我们想要找出它是否对电子邮件选择率产生了负面影响。为此,我们将比较变更之前的两周和变更之后的两周,看看选择参与率是否有统计显著性差异。我们可以使用一周或三周的期间,但选择两周是因为它足够长,可以允许一些星期几的变化,同时也足够短,以限制其他可能影响用户愿意选择参与的因素数量。

变体通过 SQL 查询中的 CASE 语句进行分配:在变更之前创建的用户标记为“pre”,而在变更之后创建的用户标记为“post”。接下来,我们从game_users表中计算每个组中用户的数量。然后,我们通过与game_actions表的LEFT JOIN,限制为具有“email_optin”动作的记录,来计算选择参与的用户数量。然后我们将这些值相除以找到选择参与的百分比。我喜欢在每个变体中包含天数的计数作为质量检查,尽管不必在分析的其余部分中执行:

SELECT 
case when a.created between '2020-01-13' and '2020-01-26' then 'pre'
     when a.created between '2020-01-27' and '2020-02-09' then 'post'
     end as variant
,count(distinct a.user_id) as cohorted
,count(distinct b.user_id) as opted_in
,count(distinct b.user_id) / count(distinct a.user_id) as pct_optin
,count(distinct a.created) as days
FROM game_users a
LEFT JOIN game_actions b on a.user_id = b.user_id 
 and b.action = 'email_optin'
WHERE a.created between '2020-01-13' and '2020-02-09'
GROUP BY 1
;

variant  cohorted  opted_in  pct_optin  days
-------  --------  --------  ---------  ----
pre      24662     14489     0.5875     14
post     27617     11220     0.4063     14
提示

许多数据库将日期输入视为字符串,如 '2020-01-13'。如果您的数据库不识别,请使用以下选项将字符串转换为日期:

cast('2020-01-13' as date)

date('2020-01-13')

'2020-01-13'::date

在这种情况下,我们可以看到,在变更之前通过入职流程的用户的电子邮件选择率明显较高——58.75%,而之后为 40.63%。将数值输入在线计算器中,确认“pre”组的比例显著高于“post”组。在这个例子中,游戏公司可能无法做太多,因为这一变更是由法规引起的。进一步的测试可能会确定,如果这是一个业务目标,提供样本内容或关于电子邮件程序的其他信息是否会鼓励更多新玩家选择参与。

在进行前后分析时,请记住,您试图了解的变化之外的其他因素可能会导致指标的增加或减少。外部事件、季节性、营销促销等因素甚至可能在几周内大幅改变环境和客户心态。因此,这种类型的分析不如真正的随机实验来证明因果关系。然而,有时这是少数可用的分析选项之一,可以生成可以在将来的控制实验中测试和完善的工作假设。

自然实验分析

自然实验 是指实体通过某种近似随机的过程获得不同体验的情况。一个组接受正常或控制体验,另一个组接受可能具有积极或消极影响的变体。通常这些实验是无意识的,比如引入软件漏洞或某个地点发生的事件。为了使这种分析有效,我们必须能够清楚地确定哪些实体受到了影响。此外,还需要一个尽可能与受影响组相似的对照组。

SQL 可用于构建变体并计算在二元结果事件中的队列大小和成功事件(或在连续结果事件中的均值、标准差和人口大小)。结果可以像任何其他实验一样插入在线计算器中。

以视频游戏数据集为例,假设在我们的数据时间段内,加拿大用户在第一次查看虚拟货币购买页面时,因为误操作多添加了一个零:每个包中的虚拟币数量增加了。例如,用户本来可以得到 10 个游戏币,现在会得到 100 个游戏币,或者本来可以得到 100 个游戏币,现在会得到 1,000 个游戏币,依此类推。我们想要回答的问题是,加拿大人是否以更高的比例转化为购买者。我们将仅与美国用户比较,而不是整个用户基础。这两个国家在地理上靠近,大多数用户使用相同语言——假设我们已经进行了其他分析,表明他们的行为相似,而其他国家的用户行为差异足以排除它们。

要进行分析,我们根据区分特征(在这种情况下是game_users表中的country字段)创建“变体”,但需要注意,根据数据集的不同,有时可能需要更复杂的 SQL。计算用户分组及其购买次数的方法与之前相同:

SELECT a.country
,count(distinct a.user_id) as total_cohorted
,count(distinct b.user_id) as purchasers
,count(distinct b.user_id) / count(distinct a.user_id) 
 as pct_purchased
FROM game_users a
LEFT JOIN game_purchases b on a.user_id = b.user_id
WHERE a.country in ('United States','Canada')
GROUP BY 1
;

country        total_cohorted  purchasers  pct_purchased
-------------  --------------  ----------  -------------  
Canada         20179           5011        0.2483
United States  45012           4958        0.1101

实际上,加拿大购买用户的比例更高——为 24.83%,而美国为 11.01%。将这些值输入在线计算器确认,加拿大的转化率在 95%置信区间下显著更高。

分析自然实验最困难的部分往往是找到一个可比较的人群,并且显示这两个人群足够相似,以支持统计检验的结论。虽然几乎不可能证明没有混杂因素,但人口统计特征和行为的仔细比较可以增加结果的可信度。由于自然实验不是真正的随机实验,因果关系的证据较弱,这一点在这类分析的呈现中应该注意。

围绕阈值的人群分析

在某些情况下,存在一个阈值,导致某些人或其他主体单位接受治疗,而其他人则没有。例如,某个特定的成绩平均值可能使学生有资格获得奖学金,某个收入水平可能使家庭有资格获得补助医疗保健,或者高流失风险评分可能会触发销售代表跟进客户。在这种情况下,我们可以利用这样一个想法:阈值两侧的主体很可能相似。因此,我们可以仅比较在正面和负面靠近阈值的那些人,而不是比较整个收到奖励或干预的人群。这种方法的正式名称是回归断点设计(RDD)。

要执行这种类型的分析,我们可以通过围绕阈值分割数据来构建“变体”,类似于我们在前/后分析中所做的。不幸的是,关于两侧值的带宽应该多宽的硬性规定并不存在。这些“变体”应该在大小上相似,并且它们应该足够大,以便在结果分析中具有显著性。一个选择是使用几个不同的范围进行多次分析。例如,您可以分析在每个组包含在阈值的 5%、7.5%和 10%内的受试者时,“治疗”组与对照组之间的差异。如果这些分析的结论一致,那么这些结论会得到更多的支持。然而,如果它们不一致,数据可能被视为无法得出结论。

与其他类型的非实验分析一样,来自 RDD 的结果应该被认为不那么确凿地证明了因果关系。潜在的混杂因素也应该受到仔细的关注。例如,如果高流失风险客户接受了多个团队的干预,或者享受到了特别折扣以鼓励他们保持客户,再加上销售代表的电话,数据可能会被这些其他变化所影响。

结论

实验分析是一个丰富的领域,通常包括本书其他部分所见的不同类型的分析,从异常检测到队列分析。数据剖析在追踪发生的问题时非常有用。当随机实验不可能时,有多种其他技术可用,而且可以使用 SQL 创建合成对照组和变体组。

在下一章中,我们将转向构建复杂的数据集进行分析,这一领域整合了我们迄今为止讨论过的各种主题。

¹ 参见https://www.mathsisfun.com/data/chi-square-test.html以获取对此测试的良好解释。

第八章:为分析创建复杂数据集

在第 3 到第七章中,我们讨论了使用 SQL 进行数据库中数据分析的多种方法。除了这些具体用例之外,有时查询的目标是组装一个具体但通用的数据集,可以用来执行各种进一步的分析。目标可能是数据库表、文本文件或商业智能工具。所需的 SQL 可能很简单,仅需要几个过滤器或聚合。然而,通常实现所需数据集的代码或逻辑可能非常复杂。此外,随着利益相关者请求额外的数据点或计算,此类代码可能会随时间更新。SQL 代码的组织、性能和可维护性变得至关重要,而这对于一次性分析来说并非如此。

在本章中,我将讨论组织代码的原则,以便更容易地共享和更新。然后我将讨论何时保留 SQL 中的查询逻辑,何时考虑通过 ETL(提取-转换-加载)代码转移到永久表。接下来,我将解释存储中间结果的选项——子查询、临时表和公共表达式(CTE)——以及在代码中使用它们的考虑因素。最后,我将介绍减小数据集大小的技术和处理数据隐私以及删除个人可识别信息(PII)的方法。

何时使用 SQL 处理复杂数据集

几乎所有为进一步分析准备的数据集都包含一些逻辑。逻辑的复杂程度可以从相对简单的情况——例如如何将表进行连接(JOIN)以及如何在 WHERE 子句中放置过滤器——到对数据的分区执行聚合、分类、解析或窗口函数的复杂计算。在为进一步分析创建数据集时,选择是保留 SQL 查询中的逻辑,还是将其推到上游的 ETL 作业或下游的其他工具,通常既是艺术也是科学。便利性、性能以及工程师的帮助可用性都是决策的因素。通常并不存在单一正确答案,但是随着您与 SQL 的工作时间越长,您会逐渐培养直觉和信心。

使用 SQL 的优势

SQL 是一种非常灵活的语言。希望在前几章中我已经说服了你,SQL 可以完成各种数据准备和分析任务。在开发复杂数据集时,这种灵活性是使用 SQL 的主要优势。

在处理数据集的初始阶段,您可能会执行许多查询。工作通常从几个分析查询开始,以了解数据。然后逐步构建查询,沿途检查转换和聚合,确保返回的结果正确。这可能会与更多的分析交替进行,当实际值与我们的预期不同时。复杂的数据集可以通过组合几个子查询来构建,这些子查询使用JOINUNION回答特定问题。运行查询并检查输出是快速的,允许快速迭代。

除了依赖表中数据的质量和及时性外,SQL 几乎没有依赖性。查询是按需运行的,不依赖于数据工程师或发布流程。分析师或数据科学家通常可以将查询嵌入到商业智能(BI)工具或 R 或 Python 代码中,而无需请求技术支持。当利益相关者需要在输出中添加另一个属性或聚合时,可以快速进行更改。

在进行新分析并且预计逻辑和结果集经常发生变化时,将逻辑保留在 SQL 代码中是理想的。此外,当查询快速且数据快速返回给利益相关者时,可能永远不需要将逻辑移动到其他位置。

何时应构建到 ETL 中

在某些情况下,将逻辑移入 ETL 流程比保留在 SQL 查询中更好,尤其是在工作于具有数据仓库或数据湖的组织时。使用 ETL 的两个主要原因是性能和可见性。

SQL 查询的性能取决于逻辑的复杂性、查询的表的大小以及底层数据库的计算资源。尽管许多查询运行速度很快,特别是在更新的数据库和硬件上,但您可能最终会编写一些具有复杂计算、涉及大型表的JOIN或笛卡尔JOIN的查询,或者以分钟或更长时间运行的查询。分析师或数据科学家可能愿意等待查询返回。然而,大多数数据使用者习惯于网站快速响应时间,如果他们等待数据超过几秒钟,就会感到沮丧。

ETL 在计划的时间背景下运行,并将结果写入表中。由于它在后台运行,可以运行 30 秒、五分钟或一个小时,而不会影响最终用户。计划通常是每天运行一次,但可以设置更短的间隔。最终用户可以直接查询生成的表,无需进行JOIN或其他逻辑,因此体验到快速的查询时间。

当 ETL 比将所有逻辑保留在 SQL 查询中更好的一个典型例子是每日快照表。在许多组织中,保留客户、订单或其他实体的每日快照对于回答分析问题非常有用。对于客户,我们可能想要计算到目前为止的总订单或访问次数,销售管道中的当前状态以及其他可能变化或累积的属性。我们在第三章关于时间序列分析和第四章关于队列分析中已经看到如何创建每日系列,包括实体不存在的天数。在个体实体级别和长时间段内,这样的查询可能变得缓慢。此外,像当前状态这样的属性可能会在源表中被覆盖,因此捕获每日快照可能是保留准确历史图片的唯一方法。开发 ETL 并将每日快照结果存储在表中通常是值得努力的。

将逻辑移到 ETL 的第二个原因是可见性。通常 SQL 查询存在于个人计算机上或者埋藏在报告代码中。其他人甚至找到查询中嵌入的逻辑,更别说理解和检查其中的错误是困难的。将逻辑移到 ETL 并将 ETL 代码存储在像 GitHub 这样的代码库中,可以让组织中的其他人更容易找到、检查和迭代它。大多数开发团队使用的代码库还存储变更历史,这是一个额外的好处,可以看到特定查询中的哪一行是何时添加或更改的。

有很多理由考虑将逻辑放入 ETL,但这种方法也有其缺点。其中一个是直到 ETL 作业运行并刷新数据之前,新结果才可用,即使底层表中已经到达了新数据。这可以通过继续针对原始数据运行 SQL 但限制到一个小时间窗口以确保查询快速来克服。这可以选择与 ETL 表上的查询结合使用,使用子查询或UNION。将逻辑放入 ETL 的另一个缺点是更难更改。更新或修复错误通常需要交给数据工程师,并进行代码测试、检入代码库并发布到生产数据仓库。因此,我通常选择等到我的 SQL 查询经过快速迭代期,组织已经审查并在使用的数据集之后,再将其移到 ETL 中。当然,使代码更难更改并强制进行代码审查是确保一致性和数据质量的优秀方法。

何时将逻辑放入其他工具

SQL 代码和查询结果在查询编辑器中经常只是分析的一部分。结果通常嵌入报告中,以表格和图形形式可视化,或在从电子表格和 BI 软件到应用统计或机器学习代码的各种工具中进一步处理。除了选择何时将逻辑上游移动到 ETL 中,我们还可以选择何时将逻辑下游移动到其他工具中。决策中关键因素是性能和具体用例。

每种工具类型都有其性能优势和限制。电子表格非常灵活,但不擅长处理大量行或跨多行的复杂计算。数据库明显具有性能优势,因此通常最好尽可能多地在数据库中进行计算,并将尽可能小的数据集传递给电子表格。

BI 工具具有各种功能,因此了解软件处理计算方式及数据使用方式至关重要。一些 BI 工具可以缓存数据(保留本地副本)以优化格式加速计算。其他工具每次向报告添加或移除字段时都会发出新的查询,因此主要利用数据库的计算能力。某些计算,如count distinctmedian,需要详细的实体级数据。如果无法预见这些计算的所有变体,可能需要传递比理想情况下更大更详细的数据集。此外,如果目标是创建一个允许多种方式探索和切片的数据集,通常详细数据更好。找到 SQL、ETL 和 BI 工具计算的最佳组合可能需要一些迭代过程。

当目标是使用 R 或 Python 等语言在数据集上执行统计或机器学习分析时,通常详细数据更好。这两种语言都可以执行与 SQL 重叠的任务,如聚合和文本处理。通常最好尽可能多地在 SQL 中执行计算,以利用数据库的计算能力,但不要过度使用。灵活性迭代通常是建模过程中的重要部分。在 SQL 或其他语言中执行计算的选择也可能取决于您对每种语言的熟悉程度和舒适度。那些在 SQL 中非常熟悉的人可能更倾向于在数据库中进行更多的计算,而那些在 R 或 Python 中更熟练的人可能更倾向于在那里进行更多的计算。

提示

尽管决定逻辑放置位置的规则不多,我会特别鼓励您遵循一个规则:避免手动步骤。在电子表格或文本编辑器中打开数据集,做出小改动,保存并继续操作足够简单。但是当您需要迭代或有新数据到达时,很容易忘记手动步骤或执行不一致。根据我的经验,没有真正的“一次性”请求。尽可能将逻辑放在某处的代码中。

SQL 是一个强大的工具,非常灵活。它也坐落在分析工作流程和工具生态系统中。决定计算放置位置可能需要一些试验和错误,因为您在 SQL、ETL 和下游工具之间进行迭代时需要考虑可行性。您对所有可用选项越熟悉和经验越丰富,您就能更好地估计权衡和继续提高工作的性能和灵活性。

代码组织

SQL 的格式化规则较少,这可能导致查询混乱。查询子句必须按正确顺序排列:SELECT 之后是 FROMGROUP BY 不能在 WHERE 之前,例如。一些关键字如 SELECTFROM保留字(即不能用作字段名、表名或别名)。然而,与其他一些语言不同,数据库会忽略换行、空白(除了分隔单词的空格)和大小写。本书中的示例查询都可以写在一行上,并且可以有或没有大写字母,除非在引号字符串中。因此,代码组织的负担落在编写查询的人身上。幸运的是,我们有一些正式和非正式的工具来保持代码组织良好,从注释到“装饰性”格式化(如缩进和 SQL 代码文件的存储选项)。

注释

大多数编码语言都有一种方法来指示将文本块视为注释并在执行过程中忽略它。SQL 有两种选择。第一种是使用两个短划线,这会将后续行中的所有内容转换为注释:

-- This is a comment

第二种选择是使用斜杠(/)和星号(*)字符开始一个注释块,该块可以跨多行,后面跟着星号和斜杠结束注释块:

/*
This is a comment block
with multiple lines
*/

许多 SQL 编辑器调整了代码内部注释的视觉样式,将其变灰或以其他方式改变颜色,以便更容易识别。

注释代码是一个好习惯,但必须承认,许多人在日常生活中很难坚持做到这一点。SQL 通常是迅速编写的,特别是在探索或剖析练习中,我们不指望长期保留我们的代码。过度注释的代码与没有注释的代码一样难以阅读。我们都曾因为我们写了这段代码,所以我们将永远能记住写这段代码的原因的想法而受苦。然而,任何继承自同事写的长查询或离开查询几个月后再回来更新它的人都会知道,解释代码可能是令人沮丧和耗时的。

为了平衡注释的负担和好处,我尝试遵循一些经验法则。首先,在任何值具有非明显含义的地方添加注释。许多源系统将值编码为整数,而它们的含义很容易被忘记。留下一条注释使含义清晰,并使代码更易于根据需要更改:

WHERE status in (1,2) -- 1 is Active, 2 is Pending

其次,对于任何其他非明显的计算或转换进行评论。这些可以是任何人未花时间对数据集进行剖析可能不知道的内容,从数据输入错误到异常值的存在:

case when status = 'Live' then 'Active'
     else status end
     /* we used to call customers Live but in
     2020 we switched it to Active */

我尝试遵循的第三个关于注释的实践是在查询包含多个子查询时留下注释。关于每个子查询计算的简短说明使得以后在质量检查或编辑较长查询时轻松跳转到相关部分变得容易:

SELECT...
FROM
( -- find the first date for each customer
    SELECT ...
    FROM ...
) a
JOIN 
( -- find all of the products for each customer
    SELECT ...
    FROM ...
) b on a.field = b.field
...
;

良好的注释需要练习和一些纪律,但对于大部分长于几行的查询来说是值得的。注释还可以用于向整体查询添加有用的信息,例如目的、作者、创建日期等等。通过在代码中添加有帮助的注释,善待你的同事和未来的自己。

大小写、缩进、括号和其他格式技巧

格式化,特别是一致的格式化,是保持 SQL 代码有条理和可读性的好方法。数据库在 SQL 中忽略大小写和空白(空格、制表符和换行),因此我们可以利用这些优势将代码格式化为更易读的块。括号不仅可以控制执行顺序(我们稍后会详细讨论),还可以在视觉上组合计算元素。

大写的词突出于其他内容之中,任何收到全大写主题邮件的人都可以确认这一点。我喜欢仅在主要从句中使用大写:SELECTFROMJOINWHERE 等等。特别是在长或复杂的查询中,能够迅速识别这些内容,从而理解SELECT从句何时结束,FROM从句何时开始,节省了我大量时间。

空白字符是组织查询部分并使其更易于找到的关键方法,以及理解哪些部分在逻辑上彼此配合。任何 SQL 查询都可以在编辑器中的单行上编写,但在大多数情况下,这会导致代码左右滚动。我喜欢在新的一行上开始每个子句(SELECTFROM等),这与大写一起帮助我跟踪每个子句的开始和结束。此外,我发现将聚合函数放在自己的行上,以及占据一些空间的函数,有助于组织。对于有两个以上 WHEN 条件的 CASE 语句,将它们分开到多行上也是一种很好的方法,可以轻松地看到和跟踪代码中正在发生的事情。例如,我们可以查询typemag(大小),解析place,然后计算earthquakes表中的记录,在WHERE子句中进行一些过滤:

SELECT type, mag
,case when place like '%CA%' then 'California'
      when place like '%AK%' then 'Alaska'
      else trim(split_part(place,',',2)) 
      end as place
,count(*)
FROM earthquakes
WHERE date_part('year',time) >= 2019
and mag between 0 and 1
GROUP BY 1,2,3
;

type                mag  place       count
------------------  ---  ----------  -----
chemical explosion  0    California  1
earthquake          0    Alaska      160
earthquake          0    Argentina   1
...                 ...  ...         ...

缩进是保持代码视觉组织的另一个技巧。在 CASE 语句中添加空格或制表符以对齐其中的 WHEN 项目是一个例子。您还在整本书的示例中看到缩进的子查询。这使得子查询在视觉上显得独立,当一个查询有多级嵌套子查询时,它使得查看和理解将被评估的顺序以及哪些子查询在级别上是对等的更加容易:

SELECT... 
FROM 
(
    SELECT...
    FROM
    (
        SELECT...
        FROM...
    ) a
    JOIN
    (
        SELECT...
        FROM
    ) b on...
) a
...
;

任意数量的其他格式选择都可以,查询将返回相同的结果。长期以来,SQL 编写者倾向于有自己的格式偏好。但是,清晰和一致的格式使得创建、维护和分享 SQL 代码更加容易。

许多 SQL 查询编辑器提供某种形式的查询格式化和着色。通常关键字会被着色,使其在查询中更易于识别。这些视觉线索使得开发和审查 SQL 查询都更加容易。如果你一直在查询编辑器中编写 SQL,请尝试在纯文本编辑器中打开一个.sql文件,看看着色效果有多大的不同。图 8-1 展示了一个 SQL 查询编辑器的示例,同样的代码在图 8-2 中展示为纯文本编辑器中的样式(请注意,在本书的某些版本中可能为灰度显示)。

图 8-1. SQL 查询编辑器 DBVisualizer 中关键字着色的屏幕截图

图 8-2. 同样的代码在文本编辑器 Atom 中的纯文本形式

从数据库的角度来看,格式化是可选的,但这是一个好的实践。一致使用间距、大写和其他格式选项有助于保持代码的可读性,因此更容易分享和维护。

存储代码

在进行注释和格式化代码之后,将其存储在某个地方以备日后使用或参考是一个好主意。

许多数据分析师和科学家使用 SQL 编辑器,通常是桌面软件。SQL 编辑器很有用,因为它们通常包含用于浏览数据库架构的工具,并且有一个代码窗口。它们将文件保存为.sql 扩展名的文件,这些文本文件可以在任何文本编辑器中打开和修改。文件可以保存在本地目录或云端文件存储服务中。

由于它们是文本文件,SQL 代码文件很容易存储在像 GitHub 这样的变更控制存储库中。使用存储库提供了一个不错的备份选项,并且便于与他人分享。存储库还会跟踪文件的变更历史,这在需要查找特定更改的时间或出于法规原因需要变更历史时非常有用。GitHub 和其他工具的主要缺点是它们通常不是分析工作流程中的必需步骤。您需要记住定期更新您的代码,并且与任何手动步骤一样,很容易忘记执行更新。

组织计算

创建复杂数据集时,我们面临的两个相关问题是确保逻辑正确和获得良好的查询性能。逻辑必须正确,否则结果将毫无意义。对于分析目的的查询性能,与事务系统不同,通常有一个“足够好”的范围。无法返回查询的查询是有问题的,但等待 30 秒和等待一分钟的结果可能并不会有很大差别。在 SQL 中,通常有多种编写返回正确结果的查询的方法。我们可以利用这一点来确保逻辑正确并调整长时间运行查询的性能。在 SQL 中,有三种主要方法来组织中间结果的计算:子查询、临时表和公共表表达式(CTE)。在我们深入研究它们之前,我们将回顾 SQL 的评估顺序。为了结束这一部分,我将介绍分组集,它可以在某些情况下替代联合查询。

理解 SQL 子句评估顺序

数据库将 SQL 代码翻译成一组操作,这些操作按顺序执行以返回请求的数据。虽然不必深入了解其工作原理就能写出分析用的 SQL,但了解数据库执行操作的顺序非常有用(有时候还是必要的,以便调试出现的意外结果)。

小贴士

许多现代数据库都有复杂的查询优化器,可以考虑查询的各个部分,以制定最高效的执行计划。尽管它们可能会按照不同于此处讨论的顺序考虑查询的各个部分,因此可能需要较少的人为查询优化,但它们不会按照此处讨论的顺序计算中间结果。

SQL 查询评估的一般顺序显示在表 8-1 中。SQL 查询通常仅包括可能的子句子集,因此实际评估包括与查询相关的步骤。

表 8-1. SQL 查询评估顺序

1 包括JOIN及其ON子句的 FROM
2 WHERE
3 包括聚合的 GROUP BY
4 HAVING
5 窗口函数
6 SELECT
7 DISTINCT
8 UNION
9 ORDER BY
10 LIMIT 和 OFFSET

首先,计算FROM子句中的表格,以及任何JOIN。如果FROM子句包含任何子查询,在继续下一步之前需要评估这些子查询。在JOIN中,ON子句指定了如何JOIN表格,这也可能过滤结果集。

提示

FROM始终首先评估,除非查询不包含FROM子句的情况下。在大多数数据库中,可以仅使用SELECT子句进行查询,如本书中的某些示例所示。仅SELECT查询可以返回系统信息,如日期和数据库版本。它还可以对常量应用数学、日期、文本和其他函数。尽管在最终分析中很少使用这样的查询,但它们对于快速测试函数或迭代复杂计算非常方便。

接下来,评估WHERE子句以确定应包含在进一步计算中的记录。请注意,WHERE在评估顺序中较早,因此不能包含稍后步骤中发生的计算结果。

接下来计算GROUP BY,包括相关的聚合函数,如countsumavg。正如您所预期的那样,GROUP BY将仅包括在FROM表格中存在的值,这是在JOINWHERE子句中进行过滤后的结果。

接下来评估HAVING。由于它跟在GROUP BY之后,HAVING可以对GROUP BY返回的聚合值进行过滤。通过将查询放置在子查询中并在主查询中应用过滤器,也可以通过聚合值进行过滤。例如,我们可能希望找到legislators_terms表中至少有一千个任期的所有州,并按照任期降序排列:

SELECT state
,count(*) as terms
FROM legislators_terms
GROUP BY 1
HAVING count(*) >= 1000
ORDER BY 2 desc
;

state  terms
-----  -----
NY     4159
PA     3252
OH     2239
...    ...

如果使用窗口函数,则在此时评估。有趣的是,因为聚合已在此时计算,所以可以在窗口函数定义中使用它们。例如,在第四章中的legislators数据集中,可以在单个查询中计算每个州的任期服务和所有州的平均任期:

SELECT state
,count(*) as terms
,avg(count(*)) over () as avg_terms
FROM legislators_terms
GROUP BY 1
;

state  terms  avg_terms
-----  -----  ---------
ND     170    746.830
NV     177    746.830
OH     2239   746.830
...    ...    ...

聚合也可以在OVER子句中使用,如下面的查询所示,它按总任期数降序排列各州:

SELECT state
,count(*) as terms
,rank() over (order by count(*) desc)
FROM legislators_terms
GROUP BY 1
;

state  terms  rank
-----  -----  ----
NY     4159   1
PA     3252   2
OH     2239   3
...    ...    ...

在这一点上,SELECT 子句最终被评估。这有点反直觉,因为聚合和窗口函数在查询的SELECT部分中被输入。然而,数据库已经处理了计算,结果可以进一步操作或直接显示。例如,聚合可以放在一个 CASE 语句中,并且可以应用数学、日期或文本函数,如果聚合的结果是这些数据类型之一。

提示

聚合函数sumcountavg返回数值类型。然而,minmax函数返回与输入相同的数据类型,并使用该数据类型的固有排序。例如,日期的minmax返回最早和最晚的日历日期,而文本字段的minmax则使用字母顺序确定结果。

SELECT之后是DISTINCT,如果在查询中存在。这意味着所有行都被计算,然后进行去重。

UNION(或UNION ALL)接下来执行。到目前为止,组成UNION的每个查询都是独立评估的。这个阶段是将结果集合并到一起。这意味着查询可以以非常不同的方式或来自不同数据集进行计算。UNION只要求列的数量相同,并且这些列具有兼容的数据类型。

ORDER BY 几乎是评估的最后一步。这意味着它可以访问之前任何计算结果来对结果集进行排序。唯一的注意事项是,如果使用了DISTINCTORDER BY 不能包含在SELECT子句中未返回的任何字段。否则,可以完全按照查询中未出现的字段对结果集进行排序。

LIMITOFFSET 在查询执行序列中最后评估。这确保返回的结果子集将完全按照查询中的任何其他子句指定的已计算结果。这也意味着LIMIT 在控制数据库在返回结果之前所做的工作量方面有些限制。当查询包含大的OFFSET值时,这可能是最明显的。为了偏移,比如说三百万条记录,数据库仍然需要计算整个结果集,找出第三百万加一条记录的位置,然后返回LIMIT指定的记录。这并不意味着LIMIT没有用处。检查少量结果可以确认计算结果,而不会用数据压倒网络或本地机器。另外,在尽可能早地使用LIMIT,比如在子查询中,仍然可以显著减少数据库所需的工作量,特别是当您开发更复杂的查询时。

现在我们对数据库评估查询和执行计算的顺序有了很好的理解,我们将转向一些选项来控制这些操作在更大更复杂查询的上下文中的操作:子查询、临时表和 CTE。

子查询

子查询通常是我们学习如何控制 SQL 中评估顺序的第一种方式,或者完成单个主查询无法实现的计算的方法。它们非常灵活,可以帮助将长查询组织成具有明确目的的小块。

子查询用括号括起来,这种表示法在数学中应该很熟悉,其中括号还会强制在其余部分之前评估等式的某些部分。括号内是一个独立的查询,在主外部查询之前进行评估。假设子查询位于FROM子句中,则结果集可以像任何其他表一样由主代码查询。在本书中,我们已经看过许多带有子查询的示例。

一个例外是一个特殊类型的子查询,称为侧向子查询,它可以访问FROM子句中先前项目的结果。使用逗号和关键字LATERAL代替JOIN,并且没有ON子句。相反,在子查询中使用先前的查询。例如,假设我们想要分析当前议员以前的党派成员资格。我们可以找到他们第一次加入其他党派的年份,并检查按当前党派分组时这种情况有多常见。在第一个子查询中,我们找到当前的议员。在第二个侧向子查询中,我们使用第一个子查询的结果返回最早的term_start,其中党派与当前党派不同:

SELECT date_part('year',c.first_term) as first_year
,a.party
,count(a.id_bioguide) as legislators
FROM
(
    SELECT distinct id_bioguide, party
    FROM legislators_terms
    WHERE term_end > '2020-06-01'
) a,
LATERAL
(
    SELECT b.id_bioguide
    ,min(term_start) as first_term
    FROM legislators_terms b
    WHERE b.id_bioguide = a.id_bioguide
    and b.party <> a.party
    GROUP BY 1
) c
GROUP BY 1,2
;

first_year  party        legislators
----------  ----------   -----------
1979.0      Republican   1
2011.0      Libertarian  1
2015.0      Democrat     1

这种情况其实相当罕见。目前只有三名立法者改变了党派,而没有哪个党派有更多的党派变更者。有其他方法可以得到相同的结果,例如,通过将查询改为JOIN,并将第二个子查询中的条件移到ON子句中:

SELECT date_part('year',c.first_term) as first_year
,a.party
,count(a.id_bioguide) as legislators
FROM
(
    SELECT distinct id_bioguide, party
    FROM legislators_terms
    WHERE term_end > '2020-06-01'
) a
JOIN
(
    SELECT id_bioguide, party
    ,min(term_start) as first_term
    FROM legislators_terms
    GROUP BY 1,2
) c on c.id_bioguide = a.id_bioguide and c.party <> a.party
GROUP BY 1,2
;

如果第二个表非常大,通过前一个子查询返回的值进行过滤可以加快执行速度。根据我的经验,使用LATERAL的情况较少,因此比其他语法理解得更少,所以最好将其保留用于无法通过其他方式有效解决的用例。

子查询在控制计算顺序和灵活性方面提供了很大的帮助。然而,在较大查询中复杂的一系列计算可能会变得难以理解和维护。在其他时候,子查询的性能太慢,或者查询根本无法返回结果。幸运的是,SQL 还有一些额外的选项可以在这些情况下帮助:临时表和公共表达式。

临时表

临时(temp)表的创建方式与数据库中的任何其他表类似,但有一个关键区别:它仅在当前会话期间存在。当你只处理非常大表的一小部分时,临时表非常有用,因为小表查询速度要快得多。当你想在多个查询中使用中间结果时,它们也很有用。由于临时表是独立的,可以在同一会话中多次查询。另一个有用的场景是在某些数据库中工作,如 Redshift 或 Vertica,这些数据库将数据分区到多个节点中。将数据插入临时表可以使分区与后续查询中将JOIN在一起的其他表对齐。临时表的主要缺点有两个。首先,它们需要数据库权限来写入数据,出于安全原因可能不允许。其次,一些 BI 工具,如 Tableau 和 Metabase,仅允许单个 SQL 语句创建数据集,¹ 而临时表至少需要两个:CREATE语句和INSERT语句用于将数据插入临时表以及使用临时表的查询语句。

要创建临时表,请使用CREATE命令,后跟关键字 TEMPORARY 和您希望为其命名的名称。然后可以定义表并使用第二个语句将其填充,或者您可以使用CREATE as SELECT一步创建和填充。例如,您可以创建一个包含已有立法者的不同州的临时表:

CREATE temporary table temp_states
(
state varchar primary key
)
;

INSERT into temp_states
SELECT distinct state
FROM legislators_terms
;

第一条语句创建表,第二条语句从查询中填充临时表的值。请注意,通过首先定义表,我需要为所有列指定数据类型(在此示例中为varcharstate列),并且可以选择使用表定义的其他元素,如设置主键。我喜欢用“temp_”或“tmp_”作为临时表名称的前缀,以提醒自己我在主查询中使用了临时表,但这并非必须。

生成临时表的更快更简单的方法是CREATE as SELECT方法:

CREATE temporary table temp_states
as
SELECT distinct state
FROM legislators_terms
;

在这种情况下,数据库根据SELECT语句返回的数据自动决定数据类型,并且未设置主键。除非出于性能原因需要精细控制,否则第二种方法将为您提供良好的服务。

由于临时表被写入磁盘,如果需要在会话期间重新填充它们,则必须DROP并重新创建表,或TRUNCATE数据。断开并重新连接到数据库也可以起作用。

公共表达式(CTEs)

CTE(公共表达式)是 SQL 语言的一个相对新功能,在 2000 年代初引入了许多主要数据库。我多年来一直在使用 SQL,没有使用它们,而是使用子查询和临时表。不得不说自从几年前意识到它们以来,它们已经在我心中稳步增长。

您可以将公共表达式视为从子查询中提取出来并放置在查询执行开始处。它创建一个临时结果集,随后可在后续查询中的任何位置使用。一个查询可以有多个 CTEs,并且 CTEs 可以使用前面 CTEs 的结果进行额外计算。

当查询的结果在后续查询中多次使用时,CTEs 特别有用。相比之下,多次定义相同的子查询既慢(因为数据库需要多次执行相同的查询),又容易出错。忘记更新每个相同子查询中的逻辑会导致最终结果错误。由于 CTEs 是单个查询的一部分,它们不需要任何特殊的数据库权限。它们还可以是将代码组织成离散块并避免杂乱嵌套子查询的有用方式。

CTE 的主要缺点是它们在开头定义,与它们的使用位置分离。当查询非常长时,这可能会使其它人解读查询更加困难,因为需要滚动到开头检查定义,然后再返回到使用 CTE 的位置理解发生了什么。良好的注释使用可以帮助解决这个问题。第二个挑战是,CTEs 使得在长查询中执行部分更加困难。在较长的查询中检查中间结果时,很容易在查询开发工具中选择并运行一个子查询。然而,如果涉及到 CTE,必须首先注释掉周围的所有代码。

要创建 CTE,我们在整个查询的开头使用WITH关键字,然后是 CTE 的名称,然后是用括号括起来的组成它的查询。例如,我们可以创建一个 CTE,计算每位立法者的第一个术语,然后在进一步的计算中使用此结果,例如在第四章介绍的队列分析中使用:

WITH first_term as
(
    SELECT id_bioguide
    ,min(term_start) as first_term
    FROM legislators_terms 
    GROUP BY 1
) 
SELECT date_part('year',age(b.term_start,a.first_term)) as periods
,count(distinct a.id_bioguide) as cohort_retained
FROM first_term a
JOIN legislators_terms b on a.id_bioguide = b.id_bioguide 
GROUP BY 1
;

periods  cohort_retained
-------  ---------------
0.0      12518
1.0      3600
2.0      3619
...      ...

查询结果与在第四章中看到的使用子查询的替代查询返回的结果完全相同。可以在同一查询中使用多个 CTE,用逗号分隔:

WITH first_cte as
(
    SELECT...
),
second_cte as
(
    SELECT...
)
SELECT...
;

公共表达式(CTEs)是一种有用的控制评估顺序、在某些情况下提高性能并组织 SQL 代码的方式。一旦熟悉语法,它们易于使用,并且在大多数主要数据库中都可用。在 SQL 中通常有多种实现方式,虽然不是必需的,但 CTEs 可以为您的 SQL 技能工具箱增添有用的灵活性。

分组集

尽管这个下一个主题并不严格控制评估顺序,但它是避免UNION并让数据库在一个单独的查询语句中完成所有工作的一种便捷方法。在GROUP BY子句内,许多主要数据库都提供了特殊的语法,包括grouping setscuberollup(尽管 Redshift 是一个例外,MySQL 只有rollup)。当数据集需要包含各种属性组合的小计时,它们非常有用。

本节示例将使用一个视频游戏销售数据集,可以在Kaggle 上获得。它包含每款游戏的名称、平台、年份、流派和游戏发行商的属性。销售数据提供了北美、欧盟、日本、其他(世界其他地区)和全球总额。表名为videogame_sales。图 8-3 展示了表格的示例。

图 8-3. videogame_sales表的示例

因此,在视频游戏数据集中,例如,我们可能希望按独立的平台、流派和发行商聚合global_sales(而不仅仅是数据中存在的三个字段的组合),但将结果输出为一个查询集。这可以通过UNION三个查询来实现。请注意,每个查询必须至少包含所有三个分组字段的占位符:

SELECT platform
,null as genre
,null as publisher
,sum(global_sales) as global_sales
FROM videogame_sales
GROUP BY 1,2,3
    UNION
SELECT null as platform
,genre
,null as publisher
,sum(global_sales) as global_sales
FROM videogame_sales
GROUP BY 1,2,3
    UNION
SELECT null as platform
,null as genre
,publisher
,sum(global_sales) as global_sales
FROM videogame_sales
GROUP BY 1,2,3
;

platform  genre      publisher        global_sales
--------  ------     ---------        ------------
2600      (null)     (null)           97.08
3DO       (null)     (null)           0.10
...       ...        ...              ... 
(null)    Action     (null)           1751.18
(null)    Adventure  (null)           239.04
...       ...        ...              ... 
(null)    (null)     10TACLE Studios  0.11
(null)    (null)     1C Company       0.10
...       ...        ...              ...

可以通过使用grouping sets在更紧凑的查询中实现这一点。在GROUP BY子句内,grouping sets之后是要计算的分组列表。前述查询可以替换为:

SELECT platform, genre, publisher
,sum(global_sales) as global_sales
FROM videogame_sales
GROUP BY grouping sets (platform, genre, publisher)
;

platform  genre      publisher        global_sales
--------  ------     ---------        ------------
2600      (null)     (null)           97.08
3DO       (null)     (null)           0.10
...       ...        ...              ... 
(null)    Action     (null)           1751.18
(null)    Adventure  (null)           239.04
...       ...        ...              ... 
(null)    (null)     10TACLE Studios  0.11
(null)    (null)     1C Company       0.10
...       ...        ...              ...

grouping sets括号内的项可以包括空白以及逗号分隔的列列表。例如,我们可以计算全球销售而不进行任何分组,此外还包括按platformgenrepublisher进行的分组,通过包含一个仅由一对括号组成的列表项,我们还可以通过coalesce替换“All”以清理输出:

SELECT coalesce(platform,'All') as platform
,coalesce(genre,'All') as genre
,coalesce(publisher,'All') as publisher
,sum(global_sales) as na_sales
FROM videogame_sales
GROUP BY grouping sets ((), platform, genre, publisher)
ORDER BY 1,2,3
;

platform  genre      publisher        global_sales
--------  ------     ---------        ------------
All       All        All              8920.44
2600      All        All              97.08
3DO       All        All              0.10
...       ...        ...              ... 
All       Action     All              1751.18
All       Adventure  All              239.04
...       ...        ...              ... 
All       All        10TACLE Studios  0.11
All       All        1C Company       0.10
...       ...        ...              ...

如果我们想计算平台、流派和发行商的所有可能组合,例如刚刚计算的各个子总计,以及平台和流派、平台和发行商以及流派和发行商的所有组合,我们可以在grouping sets中指定所有这些组合。或者我们可以使用方便的cube语法,它可以为我们处理所有这些:

SELECT coalesce(platform,'All') as platform
,coalesce(genre,'All') as genre
,coalesce(publisher,'All') as publisher
,sum(global_sales) as global_sales
FROM videogame_sales
GROUP BY cube (platform, genre, publisher)
ORDER BY 1,2,3
;

platform  genre      publisher        global_sales
--------  ------     ---------        ------------
All       All        All              8920.44
PS3       All        All              957.84
PS3       Action     All              307.88
PS3       Action     Atari            0.2
All       Action     All              1751.18
All       Action     Atari            26.65
All       All        Atari            157.22
...       ...        ...              ...

第三个选项是函数rollup,它返回的数据集由括号中字段的顺序决定其组合,而不是所有可能的组合。因此,前述查询加上以下子句:

GROUP BY rollup (platform, genre, publisher)

返回以下组合的聚合结果:

platform, genre, publisher
platform, genre
platform

但是该查询返回以下组合的聚合结果:

platform, publisher
genre,publisher
genre
publisher

虽然可以使用UNION来生成相同的输出,但grouping setscuberollup选项在需要多级聚合时是空间和时间的重要节省者,因为它们可以减少代码行数和底层数据库表的扫描次数。曾经我创建过一个数百行长的查询,使用UNION生成动态网站图形的输出,需要预先计算所有可能的过滤器组合。质量检查是一项巨大的工作,更新则更加糟糕。利用grouping sets以及 CTE,可能会大大减少代码量,使得编写和维护更加简洁。

管理数据集大小和隐私问题

在处理好 SQL 逻辑、组织代码并提高效率之后,我们通常面临另一个挑战:结果集的大小。数据存储变得越来越便宜,这意味着组织存储了越来越大的数据集。计算能力也在不断增强,使得我们可以以前几章看到的复杂方式处理这些数据。然而,仍然会出现瓶颈,可能是在下游系统如 BI 工具中,也可能是在传递大数据集之间可用带宽方面。此外,数据隐私是一个重要的关注点,影响我们如何处理敏感数据。因此,在本节中我将讨论一些限制数据集大小的方法,以及数据隐私的考虑事项。

使用%、mod 进行抽样

减少结果集大小的一种方法是使用源数据的样本。抽样意味着仅使用数据点或观测的子集。当数据集足够大且子集代表整体人群时,这是合适的。例如,通常可以对网站流量进行抽样,并仍保留大部分有用的见解。在进行抽样时有两个选择。第一个选择是样本的大小,要在减少数据集大小和不丢失太多关键细节之间找到合适的平衡。样本可能包括数据点的 10%、1%或 0.1%,这取决于初始体积。第二个选择是执行抽样的实体。我们可以对网站访问进行 1%的抽样,但如果分析的目标是了解用户如何浏览网站,则对网站访客进行 1%的抽样会更好,以保留所有用户的数据点样本。

最常见的抽样方式是在WHERE子句中通过对实体级标识符应用函数来过滤查询结果。许多 ID 字段存储为整数。如果是这种情况,取模是实现正确结果的快速方式。取模是一个数除以另一个数时的整数余数。例如,10 除以 3 等于 3,余数(取模)为 1。SQL 有两种等效的方法来找到取模——用%符号和mod函数:

SELECT 123456 % 100 as mod_100;

mod_100
-------
56

SELECT mod(123456,100) as mod_100;

mod_100
-------
56

两者返回相同的答案,即 56,这也是输入值123456的最后两位数字。要生成数据集的 1%样本,在WHERE子句中放置任一语法,并将其设置为一个整数——在本例中为 7:

SELECT user_id, ...
FROM table
WHERE user_id % 100 = 7
;

取模 100 生成 1%样本,取模 1,000 生成 0.1%样本,取模 10 生成 10%样本。虽然按 10 的倍数抽样很常见,但不是必需的,任何整数都可以使用。

从包含字母和数字的字母数字标识符中进行抽样并不像从纯数字标识符进行抽样那样直接。可以使用字符串解析函数来仅隔离前几个或最后几个字符,并对它们应用过滤器。例如,我们可以使用right函数从字符串中解析出最后一个字符,然后仅对以字母“b”结尾的标识符进行抽样:

SELECT user_id, ...
FROM table
WHERE right(user_id,1) = 'b'
;

假设任何大写或小写字母或数字都是可能的值,这将导致约 1.6%(1/62)的样本。要返回更大的样本,请调整过滤器以允许多个值:

SELECT user_id, ...
FROM table
WHERE right(user_id,1) in ('b','f','m')
;

要创建较小的样本,请包括多个字符:

SELECT user_id, ...
FROM table
WHERE right(user_id,2) = 'c3'
;
提示

当进行抽样时,值得验证所使用的生成样本的函数是否确实创建了数据的随机或接近随机的抽样。在我之前的某个角色中,我们发现某些类型的用户更有可能在其用户 ID 的最后两位数字中具有特定的组合。在这种情况下,使用mod函数生成 1%样本导致结果中明显的偏差。特别是字母数字标识符通常在字符串的开头或结尾有共同的模式,数据分析可以帮助识别这些模式。

抽样是通过数量级减少数据集大小的简单方法。它既可以加快 SQL 语句中的计算速度,又可以使最终结果更加紧凑,从而更快速、更容易地转移到另一个工具或系统中。然而,有时从抽样中丢失细节是不可接受的,因此需要其他技术。

减少维度

属性组合的不同组合数量,或者维度,极大地影响数据集中的记录数量。为了理解这一点,我们可以进行一个简单的思维实验。假设我们有一个包含 10 个不同值的字段,并且我们使用count函数统计记录数,并按照该字段进行分组。查询将返回 10 个结果。现在加入第二个字段,也有 10 个不同的值,统计记录数,并按照这两个字段分组。查询将返回 100 个结果。再加入第三个字段,每个字段有 10 个不同的值,查询结果将增长到 1,000 个结果。即使在查询的表中并非所有这三个字段的组合实际存在,很明显,向查询中添加额外的字段可以显著增加结果的大小。

在进行分析时,我们通常可以控制字段的数量,并过滤包含的值,以便得到可管理的输出。然而,在准备数据集以在其他工具中进行进一步分析时,目标通常是提供灵活性,因此包含许多不同的属性和计算。为了尽可能保留详细信息同时管理数据的总体大小,我们可以使用一个或多个分组技术。

日期和时间的细化程度通常是减少数据大小的明显方法之一。与利益相关者沟通,确定是否需要每日数据,例如,或者每周或每月汇总是否同样有效。按月份和星期几分组数据可能是在提供不同工作日与周末模式可见性的同时进行数据聚合的解决方案。限制返回时间长度始终是一种选择,但这可能会限制对长期趋势的探索。我曾看到数据团队提供一个将数据聚合到月度级别并覆盖多年的数据集,同时另一个数据集包含相同属性,但数据粒度为每日甚至每小时,时间窗口更短。

文本字段是另一个可能节省空间的检查位置。拼写或大小写的差异可能导致比实际有用的更多的不同值。应用在第五章讨论过的文本函数,比如lowertriminitcap,可以标准化值,通常使数据对利益相关者更有用。REPLACE 或 CASE 语句可用于进行更微妙的调整,比如调整拼写或将更新为新值的名称更改。

有时候,仅对较长列表中的少数值感兴趣进行分析,因此对于这些值保留详细信息,而将其余的值分组是有效的。在处理地理位置时,我经常看到这种情况。世界上有接近两百个国家,但通常只有少数几个国家有足够的客户或其他数据点,使得对它们进行单独报告具有价值。在第四章中使用的legislators数据集包含 59 个州的值,其中包括 50 个州以及有代表的美国领土。我们可能希望创建一个数据集,其中详细列出人口最多的五个州(目前是加利福尼亚州、德克萨斯州、佛罗里达州、纽约州和宾夕法尼亚州),然后使用 CASE 语句将其余的州分组为“其他”:

SELECT case when state in ('CA','TX','FL','NY','PA') then state 
            else 'Other' end as state_group
,count(*) as terms
FROM legislators_terms
GROUP BY 1
ORDER BY 2 desc
;

state_group  count
-----------  -----
Other        31980
NY           4159
PA           3252
CA           2121
TX           1692
FL           859

查询结果仅返回了 6 行,从 59 行减少到 6 行,这代表了显著的减少。为了使列表更加动态,我们可以首先在子查询中对值进行排名,例如按照不同的id_bioguide(议员 ID)值进行排序,然后返回前 5 个值的state值和“其他”:

SELECT case when b.rank <= 5 then a.state 
            else 'Other' end as state_group
,count(distinct id_bioguide) as legislators            
FROM legislators_terms a 
JOIN
(
    SELECT state
    ,count(distinct id_bioguide)
    ,rank() over (order by count(distinct id_bioguide) desc) 
    FROM legislators_terms
    GROUP BY 1
) b on a.state = b.state
GROUP BY 1
ORDER BY 2 desc
;

state_group  legislators
-----------  -----------
Other        8317
NY           1494
PA           1075
OH           694
IL           509
VA           451

在第二个列表中,有几个州发生了变化。如果我们继续使用新数据点更新数据集,动态查询将确保输出始终反映当前的顶级值。

通过将数据转换为标志值,也可以减少维度。标志通常是二进制的(即只有两个值)。布尔值 TRUE 和 FALSE 可以用来编码标志,也可以使用 1 和 0,“是”和“否”,或任何其他有意义的一对字符串。当阈值重要时,但超过此阈值的详细信息较不重要时,标志非常有用。例如,我们可能想知道网站访客是否完成了购买,但购买的确切数量则较不重要。

legislators数据集中,议员任职的期数有 28 个不同的数字。然而,我们可能只想在输出中包含是否至少任职两届的信息,我们可以通过将详细值转换为标志来实现:

SELECT case when terms >= 2 then true else false end as two_terms_flag
,count(*) as legislators
FROM
(
    SELECT id_bioguide
    ,count(term_id) as terms
    FROM legislators_terms
    GROUP BY 1
) a
GROUP BY 1
;

two_terms_flag  legislators
--------------  -----------
false           4139
true            8379

至少有两届任职的议员比只任职一届的议员多大约一倍。与数据集中的其他字段结合使用时,这种转换可以导致更小的结果集。

有时简单的真/假或存在/缺失指示器并不能完全捕捉所需的细微差别。在这种情况下,可以将数值数据转换为几个级别,以保留一些额外的细节。这可以通过 CASE 语句完成,返回值可以是数字或字符串。

我们可能不仅想知道议员是否任职第二届,还想为那些任职 10 届或更多届的人提供另一个指标:

SELECT 
case when terms >= 10 then '10+'
     when terms >= 2 then '2 - 9'
     else '1' end as terms_level
,count(*) as legislators
FROM
(
    SELECT id_bioguide
    ,count(term_id) as terms
    FROM legislators_terms
    GROUP BY 1
) a
GROUP BY 1
;
terms_level  legislators
-----------  -----------
1            4139
2 - 9        7496
10+          883

在这里,我们将 28 个不同的值减少到了 3 个,同时保留了单术语立法者、连任者和在任期间表现出色的概念。这样的分组或区分在许多领域中都存在。与这里讨论的所有转换一样,可能需要一些试验和错误来找到最有意义的阈值,以满足利益相关者的需求。找到详细信息和聚合之间的正确平衡可以大大减少数据集的大小,从而通常加快下游应用程序的交付时间和性能。

PII 和数据隐私

数据隐私是当今数据专业人士面临的最重要问题之一。具有多个属性的大数据集可以进行更强大的分析,提供详细的洞见和建议。然而,当数据集涉及个人时,我们需要注意数据收集和使用的道德和法规维度。围绕病人、学生和金融服务客户的隐私的法规已经存在多年。近年来,关于消费者数据隐私权的法律也相继出台。欧盟通过的《通用数据保护条例》(GDPR)可能是最为人知晓的一部分。其他法规包括加州消费者隐私法案(CCPA)、澳大利亚隐私原则以及巴西的《通用数据保护法》(LGPD)。

这些及其他法规涵盖了对个人身份信息(PII)的处理、存储以及(在某些情况下)删除。一些 PII 的类别是显而易见的:姓名、地址、电子邮件、出生日期和社会安全号码。PII 还包括健康指标,如心率、血压和医疗诊断。位置信息,如 GPS 坐标,也被视为 PII,因为少数 GPS 位置可以唯一标识一个人。例如,我家和我孩子学校的 GPS 读数可以唯一标识我的家庭中的某个人。办公室的第三个 GPS 点可以唯一标识我。作为数据从业者,熟悉这些法规涵盖的内容,并与组织中的隐私法律专家讨论它们对你工作的影响是值得的,他们将具有最新的信息。

数据分析中处理包含个人身份信息(PII)的最佳实践是避免在输出中包含 PII 本身。可以通过聚合数据、替换值或对值进行哈希处理来实现这一点。

大多数分析的目标是发现趋势和模式。通常情况下,计算客户数量和平均行为,而不是在输出中包含个人详细信息。聚合通常会删除 PII;然而,需要注意的是,一组具有用户计数为 1 的属性可能会被追溯到个人。可以将这些视为异常值,并从结果中删除,以保持更高的隐私度。

如果出于某种原因需要个体数据——例如能够在下游工具中计算不同用户数量——我们可以用随机替代值替换有问题的值,以保持唯一性。row_number 窗口函数可用于为表中的每个个体分配新值:

SELECT email
,row_number() over (order by ...) 
FROM users
;

在这种情况下的挑战是找到一个足以使排序足够随机的字段,以便我们可以考虑生成的用户标识符为匿名化。

另一种选择是对值进行哈希处理。哈希处理采用输入值并使用算法创建新的输出值。特定输入值始终会产生相同的输出,这使得它成为在保持唯一性的同时模糊敏感值的良好选择。md5 函数可用于生成哈希值:

SELECT md5('my info');

md5
--------------------------------
0fb1d3f29f5dd1b7cabbad56cf043d1a
警告

md5 函数对输入值进行哈希处理,但不对其进行加密,因此可以通过逆向操作获取原始值。对于高度敏感的数据,应与数据库管理员合作,确实加密数据。

如果可能的话,避免在 SQL 查询的输出中包含 PII 始终是最佳选择,因为这样可以避免将其扩散到其他系统或文件中。替换或掩盖这些值是次佳选择。您还可以探索安全的数据共享方法,例如直接在数据库和电子邮件系统之间开发安全的数据管道,以避免将电子邮件地址写入文件中。通过与技术和法律同事的慎重合作,可以在保护个人隐私的同时实现高质量的分析。

结论

在每次分析周围,都需要做出许多关于组织代码、管理复杂性、优化查询性能和在输出中保护隐私的决策。在本章中,我们讨论了许多选项、策略和可以帮助完成这些任务的特殊 SQL 语法。尽量不要被所有这些选项所淹没,也不要因为没有掌握这些主题就认为自己不能成为高效的数据分析师或数据科学家。并非所有技术在每次分析中都是必需的,通常也有其他方法可以完成工作。您在使用 SQL 分析数据的时间越长,越有可能遇到适用一个或多个这些技术的情况。

¹ 尽管在 Tableau 的情况下,可以通过初始 SQL 选项绕过此问题。

第九章:总结

在整本书中,我们看到 SQL 是一个灵活而强大的语言,可用于各种数据分析任务。从数据概要到时间序列、文本分析和异常检测,SQL 可以处理许多常见需求。可以在任何给定的 SQL 语句中组合技巧和函数,以进行实验分析并构建复杂数据集。虽然 SQL 不能实现所有分析目标,但它非常适合分析工具生态系统。

在这个最终章节中,我将讨论一些额外的分析类型,并指出本书涵盖的各种 SQL 技术如何结合起来完成它们。然后我将用一些资源结束,这些资源可以帮助你继续掌握数据分析之旅,或者深入研究特定主题。

漏斗分析

漏斗由一系列必须完成的步骤组成,以达到一个明确定义的目标。目标可能是注册服务、完成购买或获得课程完成证书。例如,网站购买漏斗中的步骤可能包括点击“添加到购物车”按钮、填写送货信息、输入信用卡,最后点击“下单”按钮。

漏斗分析结合了时间序列分析(详见第三章)和队列分析(详见第四章)的元素。漏斗分析的数据来自事件的时间序列,尽管在这种情况下,这些事件对应于不同的现实行为,而不是相同事件的重复。从一步到另一步的留存率是漏斗分析的一个关键目标,尽管在这种情况下,我们经常使用术语转化。通常,实体在流程的各个步骤中退出,并且每个阶段的实体数量的图表看起来像一个家庭漏斗— 因此得名。

此类型的分析用于识别摩擦、困难或混淆的领域。大量用户退出或许多用户未完成的步骤提供了优化机会。例如,一个在显示包括运费在内的总金额之前要求信用卡信息的结账流程可能会拒绝一些潜在购买者。在此步骤之前显示总金额可能促进更多完整的购买。这些变化通常是实验的主题,详情请参阅第七章。漏斗也可以监测以便检测意外的外部事件。例如,完成率的变化可能对应于良好(或不良)的公关活动,或竞争对手的定价或策略的变化。

漏斗分析的第一步是确定所有有资格进入过程的用户、客户或其他实体的基础人口。接下来,组装每个感兴趣步骤的完成数据集,包括最终目标。通常这包括一个或多个LEFT JOIN,以包括所有基础人口,以及完成每个步骤的人。然后,通过总count来计算每个步骤中的用户,并将这些步骤计数按总count进行分割。有两种设置查询的方法,取决于是否需要所有步骤。

当漏斗中的所有步骤都是必需的,或者如果您只想包括已完成所有步骤的用户时,将每个表LEFT JOIN到前一个表格:

SELECT count(a.user_id) as all_users
,count(b.user_id) as step_one_users
,count(b.user_id) / count(a.user_id) as pct_step_one
,count(c.user_id) as step_two_users
,count(c.user_id) / count(b.user_id) as pct_one_to_two
FROM users a
LEFT JOIN step_one b on a.user_id = b.user_id
LEFT JOIN step_two c on b.user_id = c.user_id
;

当用户可以跳过一步时,或者如果您希望允许这种可能性时,将每个表LEFT JOIN到包含完整人群的表格,并计算该起始组的份额:

SELECT count(a.user_id) as all_users
,count(b.user_id) as step_one_users
,count(b.user_id) / count(a.user_id) as pct_step_one
,count(c.user_id) as step_two_users
,count(c.user_id) / count(b.user_id) as pct_step_two
FROM users a
LEFT JOIN step_one b on a.user_id = b.user_id
LEFT JOIN step_two c on a.user_id = c.user_id
;

这是一个微妙的差别,但值得注意并根据具体情况进行调整。考虑包括时间框,只包括在特定时间范围内完成动作的用户,如果用户在长时间后可以重新进入漏斗。漏斗分析还可以包括其他维度,例如队列或其他实体属性,以促进比较并生成关于为何漏斗表现良好或不佳的额外假设。

流失、停滞和其他离开的定义

在第四章中讨论了流失的话题,因为流失本质上是留存的相反。通常组织希望或需要为了直接衡量而制定流失的具体定义。在某些情况下,有合同规定的结束日期,例如 B2B 软件。但通常流失是一个模糊的概念,基于时间的定义更为合适。即使存在合同结束日期,测量客户停止使用产品的时间也可以是即将取消合同的预警信号。流失定义也可以应用于某些产品或功能,即使客户并未完全从组织中流失。

基于时间的流失指标是在客户在一段时间内没有购买或与产品互动时算作流失,通常范围从 30 天到一年不等。确切的时间长度很大程度上取决于业务类型和典型的使用模式。要得出良好的流失定义,您可以使用间隔分析来找到购买或使用之间的典型时间段。要进行间隔分析,您将需要一系列动作或事件的时间序列,lag窗口函数以及一些日期数学。

作为示例,我们可以计算代表们任期间的典型间隔,使用第四章中介绍的议员数据集。我们将忽略政客通常被选出而不是选择离开的事实,因为除此之外,该数据集具有进行此类分析的正确结构。首先,我们将找到平均间隔。为此,我们创建一个子查询,计算每位立法者每个任期的start_date与上一个start_date之间的间隔,然后在外部查询中找到平均值。可以使用lag函数找到上一个start_date,并使用age函数计算间隔作为时间间隔:

SELECT avg(gap_interval) as avg_gap
FROM
(
    SELECT id_bioguide, term_start
    ,lag(term_start) over (partition by id_bioguide 
                           order by term_start) 
                           as prev
    ,age(term_start,
         lag(term_start) over (partition by id_bioguide 
                               order by term_start)
         ) as gap_interval
    FROM legislators_terms
    WHERE term_type = 'rep'
) a
WHERE gap_interval is not null
;

avg_gap
-------------------------------------
2 years 2 mons 17 days 15:41:54.83805

正如我们所预期的那样,平均值接近两年,这是合理的,因为该职位的任期长度为两年。我们还可以创建间隔的分布以选择一个现实的流失阈值。在这种情况下,我们将间隔转换为月份:

SELECT gap_months, count(*) as instances
FROM
(
    SELECT id_bioguide, term_start
    ,lag(term_start) over (partition by id_bioguide 
                           order by term_start) 
                           as prev
    ,age(term_start,
         lag(term_start) over (partition by id_bioguide 
                               order by term_start)
         ) as gap_interval
    ,date_part('year',
               age(term_start,
                   lag(term_start) over (partition by id_bioguide
                                         order by term_start)
                   )
              ) * 12
     + 
     date_part('month',
               age(term_start,
                   lag(term_start) over (partition by id_bioguide 
                                         order by term_start)
                   )
              ) as gap_months
    FROM legislators_terms
    WHERE term_type = 'rep'
) a
GROUP BY 1
;

gap_months  instances
----------  ---------
1.0         25
2.0         4
3.0         2
...         ...

如果您的数据库不支持date_part,可以使用extract作为替代方法(参见第三章中的说明和示例)。输出可以绘制,如图 9-1 所示。由于存在长尾月份,该图被放大以显示大多数间隔的范围。最常见的间隔是 24 个月,但每月也有数百个实例延伸至 32 个月。在 47 和 48 个月处还有另一个小峰。有了平均值和分布,我可能会设定 36 或 48 个月的阈值,并表示在此窗口内未被重新选举的任何代表已经“流失”。

图 9-1. 代表任期开始日期间隔长度的分布,显示从 10 到 59 个月的范围

一旦确定了客户流失的定义阈值,可以通过“自上次”分析监控客户群体。这可以指上次购买、上次付款、上次打开应用程序的时间,或者对组织相关的任何基于时间的度量。为了进行这种计算,您需要一个包含每位客户最近日期或时间戳的数据集。如果从时间序列开始,首先在子查询中找到每位客户的最近时间戳。然后应用日期数学运算,找到该日期与当前日期之间的经过时间,或者数据集中最新日期,如果数据组装后经过了一段时间。

例如,我们可以从legislators_terms表中找到距离上次选举的年份分布。在子查询中,使用max函数计算最新的起始日期,然后使用age函数找出自那时以来的时间间隔。在本例中,数据集中的最大日期是 2019 年 5 月 5 日。在具有最新数据的数据集中,可以替换为current_date或等效表达式。外部查询使用date_part找到间隔中的年数,并计算立法者的数量:

SELECT date_part('year',interval_since_last) as years_since_last
,count(*) as reps
FROM
(
    SELECT id_bioguide
    ,max(term_start) as max_date
    ,age('2020-05-19',max(term_start)) as interval_since_last
    FROM legislators_terms
    WHERE term_type = 'rep'
    GROUP BY 1
) a
GROUP BY 1
;

years_since_last  reps
----------------  -----
0.0               6
1.0               440
2.0               1
...               ...

相关概念是“潜伏”,通常作为完全活跃客户和流失客户之间的中间阶段使用,也可能被称为“休眠”。潜伏客户可能更容易流失,因为我们有一段时间没见到他们,但基于我们的过往经验,他们仍然有相当大的可能性会回归。在消费服务中,我见过“潜伏”覆盖从 7 到 30 天的期间,“流失”被定义为超过 30 天不使用服务的客户。公司经常尝试重新激活潜伏用户,使用的策略从电子邮件到支持团队的接触不等。通过首先找到他们的“上次活跃时间”并使用 CASE 语句根据适当的天数或月数对其进行标记,可以将每个州的客户进行定义。例如,我们可以根据他们当选多久来分组代表:

SELECT 
case when months_since_last <= 23 then 'Current'
     when months_since_last <= 48 then 'Lapsed'
     else 'Churned' 
     end as status
,sum(reps) as total_reps     
FROM
(
    SELECT 
    date_part('year',interval_since_last) * 12 
      + date_part('month',interval_since_last)
      as months_since_last
    ,count(*) as reps
    FROM
    (
        SELECT id_bioguide
        ,max(term_start) as max_date
        ,age('2020-05-19',max(term_start)) as interval_since_last
        FROM legislators_terms
        WHERE term_type = 'rep'
        GROUP BY 1
    ) a
    GROUP BY 1
) a
GROUP BY 1
;

status    total_reps
-------   ----------
Churned   10685
Current   446
Lapsed    105

这个数据集包含超过两百年的立法者任期,所以当然其中许多人已经去世,有些仍然健在但已经退休。在商业环境中,我们希望我们的流失客户数量没有比我们当前客户多出这么多,我们也想进一步了解潜伏客户的情况。

大多数组织非常关注客户流失问题,因为获取新客户通常比留住现有客户更昂贵。要了解任何状态下的客户或上次活跃时间范围的更多信息,这些分析可以进一步通过数据集中可用的任何客户属性进行切片分析。

购物篮分析

我有三个孩子,每次去杂货店时,我的购物篮(或者更常见的购物车)很快就会被各种食品填满,供他们一周食用。牛奶、鸡蛋和面包通常都在其中,但其他物品可能会根据时令水果的变化、孩子们是否在上学或放假以及我们是否计划做特别的餐食而有所改变。购物篮分析的名字来自于分析消费者一起购买的产品,以找出可用于营销、店铺布置或其他战略决策的模式。购物篮分析的目标可能是找到一起购买的物品组合。它也可以围绕特定产品展开:当有人购买冰淇淋时,他们还会购买什么?

虽然篮子分析最初是围绕单个交易中一起购买的物品,但这个概念可以通过几种方式扩展。零售商或电子商务店可能对客户在其生命周期内购买的物品篮感兴趣。还可以分析服务和产品功能的使用情况。常一起购买的服务可能会被捆绑成一个新的优惠,例如旅行网站提供的飞行、酒店和租车一起预订的优惠。经常一起使用的产品功能可能会放在同一个导航窗口中,或者用于建议应用程序中的下一步操作。篮子分析还可用于识别利益相关者人物角色或细分,然后用于其他类型的分析。

要找出最常见的篮子,使用篮子中的所有物品,可以使用string_agg函数(或类似的函数,取决于数据库类型——参见第五章)。例如,假设我们有一个purchases表,每个customer_id购买的每个product都有一行记录。首先,在子查询中使用string_agg函数找到每个客户购买的产品列表。然后按此列表进行GROUP BY并计算客户数量。

SELECT products
,count(customer_id) as customers
FROM
(
    SELECT customer_id
    ,string_agg(product,', ') as products
    FROM purchases
    GROUP BY 1
) a
GROUP BY 1
ORDER BY 2 desc
;

当有相对较少的可能项时,这种技术效果很好。另一个选择是找到一起购买的产品对。为此,将purchases表与自身进行JOIN,在customer_idJOIN。第二个JOIN条件解决了仅在顺序不同的重复条目的问题。例如,想象一个购买了苹果和香蕉的客户——如果没有这个子句,结果集将包括“苹果,香蕉”和“香蕉,苹果”。子句b.product > a.product确保只包括这些变体中的一个,并且还过滤掉与自身匹配的产品:

SELECT product1, product2
,count(customer_id) as customers
FROM
(
    SELECT a.customer_id
    ,a.product as product1
    ,b.product as product2
    FROM purchases a
    JOIN purchases b on a.customer_id = b.customer_id 
    and b.product > a.product
) a
GROUP BY 1,2
ORDER BY 3 desc
;

可以通过添加额外的JOIN来扩展到包含三个或更多产品。要包括仅包含一个项目的篮子,将JOIN更改为LEFT JOIN

运行购物篮分析时存在一些常见挑战。首先是性能问题,特别是在有大量产品、服务或功能的目录时。结果计算在数据库上可能变得缓慢,尤其是当目标是查找包含三个或更多项的组时,因此 SQL 包含三个或更多个自连接。考虑使用WHERE子句过滤表,以移除不经常购买的项目,然后执行JOIN。另一个挑战是少数项目非常普遍,以至于淹没所有其他组合。例如,牛奶购买频率如此之高,以至于与任何其他项目一起的组合位于组合列表的顶部。查询结果虽然准确,但在实际意义上可能仍然无意义。在这种情况下,考虑使用WHERE子句彻底删除最常见的项目之一,然后执行JOIN,这样做还有助于通过使数据集变小来改善查询性能。

购物篮分析的最后一个挑战是自我实现的预言。在购物篮分析中一起出现的物品可能会一起进行营销,增加它们一起购买的频率。这可能加强一起营销的案例,导致更多的共购买,依此类推。甚至更匹配的产品可能永远没有机会,只是因为它们没有出现在原始分析中并成为促销候选。著名的啤酒和尿布相关性就是其中的一个例子。各种机器学习技术和大型在线公司已经尝试解决这个问题,这个领域还有许多有趣的分析方向有待开发。

资源

作为一种职业(甚至是一种爱好!),数据分析需要技术熟练、领域知识、好奇心和沟通技巧的综合运用。我想分享一些我最喜欢的资源,希望你在继续学习和在真实数据集上练习新技能时能够从中受益。

书籍和博客

虽然本书假设您具有 SQL 的工作知识,但对于基础知识或复习的良好资源包括:

  • Forta, Ben. Sams 每天 10 分钟学会 SQL. 第 5 版. Hoboken, NJ: Sams, 2020.

  • 软件公司 Mode 提供一个SQL 教程,带有交互式查询界面,非常适合练习你的技能。

没有单一普遍接受的 SQL 风格,但你可能会发现SQL 风格指南现代 SQL 风格指南有用。请注意,它们的风格与本书使用的风格或彼此并不完全相同。我认为使用既一致又易读的风格是最重要的考虑因素。

你对分析方法和结果传达方式的处理通常和你编写的代码一样重要。两本锤炼这两个方面的好书是:

  • Hubbard, Douglas W. How to Measure Anything: Finding the Value of “Intangibles” in Business. 第 2 版。霍博肯,新泽西州:Wiley,2010 年。

  • Kahneman, Daniel. Thinking, Fast and Slow. 纽约:Farrar, Straus and Giroux,2011 年。

Towards Data Science 博客是关于多种分析主题文章的重要来源。尽管许多帖子侧重于 Python 作为编程语言,但方法和技术通常可以适应 SQL。

有趣地探讨相关性与因果关系,请参阅Tyler Vigen 的虚假相关性

正则表达式可能有些棘手。如果你想增加理解或解决本书未涵盖的复杂情况,一个很好的资源是:

  • Forta, Ben. Learning Regular Expressions. 波士顿:Addison-Wesley,2018 年。

随机化测试有着悠久的历史,并涉及自然和社会科学的多个领域。然而,与统计学相比,在线实验分析仍然相对较新。许多经典的统计学文本提供了很好的介绍,但讨论的问题样本量非常小,因此未能解决在线测试的许多独特机会和挑战。几本讨论在线实验的好书包括:

  • Georgiev, Georgi Z. Statistical Methods in Online A/B Testing. 索非亚,保加利亚:自行出版,2019 年。

  • Kohavi, Ron, Diane Tang, and Ya Xu. Trustworthy Online Controlled Experiments: A Practical Guide to A/B Testing. 剑桥,英国:剑桥大学出版社,2020 年。

Evan Miller 的 Awesome A/B Tools提供了用于二元和连续结果实验的计算器,以及几个可能对本书范围之外的实验设计有用的其他测试。

数据集

学习和提高 SQL 技能的最佳方法是在真实数据上应用它们。如果你有工作并且可以访问组织内的数据库,那是一个很好的起点,因为你可能已经了解数据生成的背景和含义。然而,有许多有趣的公共数据集可供分析,涵盖各种各样的主题。以下是一些寻找有趣数据集的好去处:

  • Data Is Plural是一份关于新奇数据集的通讯,Data Is Plural 档案是一个可搜索的数据集宝库。

  • FiveThirtyEight是一个通过数据视角报道政治、体育和科学的新闻网站。这些报道背后的数据集可以在FiveThirtyEight GitHub 站点找到。

  • Gapminder是一个瑞典基金会,每年发布许多人类和经济发展指标的数据,包括许多来自世界银行的数据。

  • 联合国发布了许多统计数据。联合国经济和社会事务部门以相对易用的格式制作了有关人口动态的数据。

  • Kaggle 主办数据分析竞赛,并有一个可以下载和分析的数据集库,即使在正式比赛之外也是如此。

  • 许多政府在各级,从国家到地方,都已经采纳了开放数据运动,并发布各种统计数据。Data.gov维护着一个包括美国和世界各地网站列表,是一个很好的起点。

最后思考

我希望您在本书中找到的技术和代码对您有所帮助。我相信,掌握您正在使用的工具的良好基础非常重要,而且有许多有用的 SQL 函数和表达式可以使您的分析更快速、更准确。然而,开发出色的分析技能不仅仅是学习最新的花哨技术或语言。优秀的分析源于提出良好的问题;花时间理解数据和领域;应用适当的分析技术得出高质量、可靠的答案;最后,以对决策支持相关的方式将结果传达给您的受众。即使在与 SQL 工作近 20 年后,我依然对找到新的应用方式、新的数据集以及等待被发现的所有见解感到兴奋。

posted @ 2025-11-19 09:22  绝不原创的飞龙  阅读(14)  评论(0)    收藏  举报