数据分析落地指南-全-

数据分析落地指南(全)

原文:zh.annas-archive.org/md5/419a04cfd034cfaa9a9d2e1105807f4d

译者:飞龙

协议:CC BY-NC-SA 4.0

第一章:1 桥接数据科学培训与现实世界之间的差距

本章涵盖

  • 使用以结果为导向的过程进行数据分析

  • 使用真实生活项目的重要数据科学概念

  • 在分析数据和学习新技能时关注实用解决方案

以下场景听起来熟悉吗?你刚刚从组织中的某个部门收到了一个数据请求,你不知道如何处理它,或者甚至不知道确切的任务是什么。在初始培训的结构化经验之外,现实世界是混乱和不确定的。你可能想知道

  • 你如何使用你现有的技能来完成对要求较高的利益相关者的项目?

  • 结构化培训环境不再存在时,你如何保持学习?

  • 你如何将你的通用技能应用于特定领域任务?

  • 你接下来需要学习什么?

任何资深数据科学家都会告诉你,所有这些问题的答案都是“经验”。通过完成本书中的八个项目,你将加速获得作为数据分析师成功所需的经验的过程。

通过完成与你在现实世界中可能遇到的类似项目,你将磨练现有的技能并学习新的技能。在这个过程中,你将完成大部分工作,但会提供一些想法来帮助你开始。为了增强特定技能,我还概述了一种方法,通过关注实际结果来让你成为一个更好的分析师。在这个过程中,你将遵循一个流程,使其更容易高效地学习新技能,并从每个新挑战中获得最大价值。

1.1 数据分析师的工具箱

分析师在开始他们的旅程时学习并带入其角色的某些基本技能。这些是能够

  • 从各种来源读取(加载)数据集

  • 将数据集合并在一起

  • 通过创建、删除、重命名和转换来操作列

  • 执行基本统计分析,例如计算平均值

  • 使用条形图、折线图或散点图等可视化方式探索你的数据

  • 通过创建适当的可视化或设计仪表板来展示你的发现

你觉得哪种工具使用起来更舒适,对于这些任务并不重要。适当的工具包括

  • 微软 Excel(或等效软件)

  • 商业智能(BI)工具,例如 Tableau 或 Power BI

  • 数据库查询语言,如 SQL,尽管这些通常没有可视化功能

  • 具备数据分析能力的编程语言,例如 Python 或 R

项目示例解决方案提供的是 Python,但重点将放在问题解决上,而不是 Python 编程语言的细节上。

通过完成项目,你将基于你的基础技能集,增加针对现实世界用例的特定技能,包括数据建模、处理分类数据、从非传统来源提取数据以及快速原型设计。在每种情况下,我都会强调解决该问题所需的精确功能类型,以便你可以找到使用你首选工具完成该任务的方法。一些项目使用了机器学习模型,但关于机器学习的深入讨论超出了我们项目的范围。

这些项目还将让你磨练更多的“元技能”,这些技能对于任何数据分析项目都是至关重要的。这些技能包括:

  • 将一个人类问题,一个模糊且不包含技术术语的问题,转换为一个可以用数据回答的问题

  • 评估现有数据,并确定它是否适合回答这个问题

  • 了解如何调整你的分析并改变你的分析问题,如果现有数据无法回答这个问题

  • 向对技术细节一无所知,甚至没有兴趣的人传达你的结果

备注:实践项目侧重于构建技术和解决问题的技能,但成为一名优秀的数据分析师还需要其他专业技能。如果你想要深入了解这些技能,我推荐 Robinson 和 Nolis 的《建立数据科学职业生涯》(Manning, 2020)。

数据分析的未来也包括人工智能(AI)工具,如 ChatGPT 和类似的大型语言模型。AI 工具不能取代你批判性思考、与利益相关者沟通或在商业环境限制下工作的能力;然而,AI 工具可以通过帮助你自动化项目中的更机械方面来加速你的工作。我会强调这种工具如何帮助解决你问题的一部分。例如,如果你不知道如何从 PDF 文件中读取数据,一个 AI 模型将能够告诉你如何在你的首选工具中访问这个功能,并给你代码片段来使用。这意味着你可以更快地解决这个特定的子问题,这将使你更有效率。

关于术语的说明

我将“数据科学”和“数据分析”这两个术语互换使用。首先,数据科学家需要成为优秀的分析师,因此我更倾向于使用“分析”这个词来描述这个学科,以及用“分析师”这个词来描述从业者。当我使用“数据科学”这个词时,我的意思仅仅是“分析数据的过程”。

我也经常提到“现实世界”。这样做的原因是为了强调在课堂上学习的内容与分析师实际工作之间的脱节。这种脱节并不意味着课堂不好;只是有多个障碍需要克服,这些障碍在课堂上无法教授。我的重点是通过对初级分析师进行培训,教他们所有在正式培训中无法学到的东西。当我说“现实世界”时,我的意思是“在学习环境之外”。

类似地,单词“利益相关者”也是一个有争议的术语。我将用它来指代你的老板、内部客户或外部客户。他们是产生你工作的请求的发起者。利益相关者是分析的目标受众,他们的工作将直接受到影响。

1.2 结果驱动型方法

在工作中,你的成功更多地取决于你交付的结果,而不是你为任务带来的技能和知识。以结果为导向意味着你的焦点始终在解决问题。你应该尽快提出一个初步解决方案——我喜欢称之为最小可行答案。这是你向利益相关者展示的内容,也是未来迭代的依据。应用这种思维方式可以简化你的工作并立即产生价值。你花时间学习一些可以立即应用的东西,从而获得更高的投资回报。这种以结果为导向的方法意味着通过实践学习,并以结果为导向地工作。

让我们通过一个来自行业的经典例子来审视这种方法。假设你为一家汽车经销商工作,需要为你经理回答一个看似简单的问题,“我们昨天卖出了多少辆车?”在理想的世界里,这是一个简单的商业智能问题,只需要过滤销售表,只显示昨天卖出的车辆,并提供答案。现实是,我们需要更深入地思考这个问题,从定义术语开始。

解决此类问题的结果驱动型分析方法如下:

  1. 理解 问题—这包括对个别单词/概念的定义。

  2. 从结果开始—一个能够激发进一步对话的最小可行答案是什么?

  3. 识别 额外资源—这些可以是数据、人员或你需要获取这个最小可行答案的访问权限。

  4. 获取 你需要获取最小可行答案的数据—这些数据甚至存在吗?

  5. 完成 获取最小可行答案的工作—理想情况下,这一步骤不应超过几天,这样你就可以快速迭代。

  6. 呈现 对利益相关者的最小可行答案—这可能是任何从闲聊到向观众做展示的内容。

  7. 如有必要,进行迭代—如果这项工作有价值,利益相关者会要求你进一步推进。

图 1.1 是此过程的视觉表示。注意从步骤 7 返回的箭头:它们突出了过程的非线性。根据步骤 6 的进展情况,你可能需要回到早期步骤,甚至是最初的步骤。

图

图 1.1 驱动结果的流程可视化

图 1.1 中的图标将在全书讨论如何将此方法应用于特定项目时使用。要将此流程应用于我们的汽车示例,我们从步骤 1,“理解问题”开始。

1.2.1 理解问题

我们所说的“汽车”是什么意思?当我从事汽车行业工作时,每当有关车辆的问题出现,我们都需要明确是否包括货车或其他不太像汽车的车辆。有时货车是相关的,有时则不然。那么,“昨天”又是什么意思呢?通常,几个日期可能与销售事件相关:客户在商店购买商品的日期,我们为客户开具商品发票的日期,客户付款的日期,甚至解决与销售相关的争议的日期。因此,当我们询问“昨天”时,我们需要知道使用哪个日期列。即使是“销售”这个词也可能含糊不清。如果客户后来退货怎么办?这意味着这个问题的答案会随时间而改变吗?我们可能会明确决定过去发生的事情不是固定的,这将使我们的分析更加复杂。顺便提一下,我们应该将这些问题的答案进行编码,并记录在业务特定的数据模型中。关于这一点,在第三章中将有更多介绍。

此步骤的输出应该是

  • 可以通过分析解决的问题

  • 明确的范围(例如,汽车的分析是否也扩展到货车?)

  • 明确定义并由相关人员达成一致的术语

这些项目应作为项目的一部分进行记录,以提高透明度和可重复性。一旦我们理解了需求,我们就应该转向设想解决方案。

1.2.2 从最后一步开始

假设我们已经就术语达成一致,我们可以从最后一步开始,决定最小可行答案的样子。这取决于请求的性质以及我们的利益相关者想用这些信息做什么。如果他们需要一个快速的粗略估计,我们可以绕过一些关于车辆类型和日期的困难问题,除非我们有良好的数据模型可以从中提取。了解利益相关者期望的答案水平对于决定这项请求需要多少努力至关重要。

此步骤的输出是一个清晰的解决方案样貌。是文档、演示文稿,甚至是一个工作原型?一旦理解了这一点,下一步就是确定所需的数据。

1.2.3 确定额外资源

即使对于简单的请求,也要立即询问“我们需要哪些数据来回答这个问题?”紧接着问“我们是否有这些数据?”在承诺任何工作之前,建议研究可用的数据,因为有些情况下我们根本没有任何直接记录我们感兴趣的概念的数据集。例如,在一个在线拍卖市场中,销售交易可能不会直接记录,因为它们可能发生在系统之外。如果在拍卖结束后通过电话达成销售协议,我们需要知道记录在哪里。

输出应该是一个列表,列出适当的数据可能看起来像什么,以回答这个问题。这应该包括组织已经可用的可能数据集和需要获取的外部数据集。

1.2.4 获取数据

现在,我们需要获取相关数据,要么通过从某处提取它,要么通过创建它。如果我们的在线拍卖市场仅用于发布和竞标,我们可能只能通过查看何时停止发布来推断何时售出。这是一个我们需要从原始发布数据中创建的数据集。在这种情况下,我们不得不停在步骤 3,重新分组,以确定创建这样的推断销售数据集是否值得努力。这取决于我们在步骤 2 中定义的最小可行答案。

输出应该是一个可触摸的数据集或提取,我们可以用它来得到我们的最小可行答案。如果数据只是一个提取,它应该代表更大的数据。

1.2.5 执行工作

你会注意到步骤 5 只是说“执行工作。”这是故意宽泛和模糊的。步骤 5 可能包括你遇到的其他所有数据分析工作流程。步骤 1-4 确保执行部分将有一个更高的投资回报率。到这一点,你已经对问题进行了足够的思考,你不会只是盲目地跳入编写代码,这在你技能新鲜且渴望交付时确实是一个诱惑。我鼓励你在步骤 1-4 上花时间,也许甚至比你本能地愿意花的时间还要多。

在汽车示例中,在这个阶段,你可能会发现一辆车的销售日期会随着时间的推移而改变,这可能是由于客户等待财务协议通过,或者之前未公开的故障随后被发现,客户提出了投诉。作为分析师,你不应该单独做出决定,关于哪个可能的日期构成实际的销售日期。在这里,你应该回到步骤 1,在与你的利益相关者进行对话后再继续你的分析。

无论分析中发生什么,记录你的具体选择和假设对于透明度和可重复性至关重要。在为每个项目提供的示例解决方案中,我已经记录了我所做的具体选择,以及如果另一位分析师做出了不同的决定,他们的路径可能会如何分歧。

这里的输出是在步骤 2“从终点开始”中决定的。这个工件应作为项目的一部分进行记录,并能够被其他分析师重复。第十二章和第十三章介绍了一个我们就是这样做的项目。

1.2.6 展示最小可行答案

结果驱动方法的一个关键组成部分是在我们可能倾向于的时间之前展示结果。我们正在寻找一个令人满意的解决方案,因此我们应该尽早讨论初步结果,以便进行迭代。数据团队过去曾失败,因为他们将自己孤立于业务的其他部分,几个月后才出现的工作没有人需要,也没有任何实际业务价值。

有人在使用你的工作吗?

我曾在一次有趣的会议上听过一个数据科学团队构建复杂销售预测模型的演示。演示纯粹是技术性的,讲述了他们如何到达他们的层次贝叶斯方法。

在问答环节,我提出了一个棘手的问题:“有人使用你的预测吗?如果是,用途是什么?”数据科学家们尴尬地承认,他们仍在努力说服业务部门他们的预测是有价值的。我怀疑如果他们早点进行那次对话,可以避免很多不必要的劳动。

这一步的输出是与我们的利益相关者进行的会议记录或演示文稿的分钟记录。这些成果也应作为项目的一部分进行记录。是否需要进一步的工作?如果是,那是什么?

1.2.7 如有必要,进行迭代

最后,你几乎总是需要对你的第一个答案进行迭代。在任何数据分析框架中包含这一步骤是很重要的,因为它突出了这项工作的固有不确定性。你永远不会一开始就拥有所有答案,包括对“这项分析需要多长时间?”这个问题的答案,这也是正常的。接受一个初稿是达到满意解决方案的必要步骤,而且越早完成初稿越好。这种迭代方法的额外好处是,你将变得更好于估计你的工作需要多长时间,因为你只需要估计一次迭代而不是整个项目。

使用以结果为导向的方法,你将培养出如何做出分析决策的直觉,这些决策帮助你专注于提供实用的解决方案,而不是一开始就提供不必要的深入答案。当然,你将在过程中学习新工具。有时,这些工具可能只用于你面前的分析。下一次分析可能需要不同的工具。这是可以的——工具来来去去,但你学到的知识和概念将保持不变,这些才是优秀分析师的标志。

重要的是要注意,这种方法并不是在必要时深入研究的替代品。我并不是鼓励任何人学习走捷径。然而,分析的关键技能是识别时间投资回报率最高的方法,这通常意味着采用广度优先而非深度优先的方法。

1.3 项目结构

这些项目旨在代表行业分析师面临的常见问题。每个项目都旨在使用基本分析师技能易于接近,且不会花费太多时间。有许多因素在起作用:你对工具的经验水平、你对当前主题的熟悉程度、你学习新概念的速度等等,但一般来说,每个项目的解决方案的第一轮迭代应该花费大约 2 小时到两天的时间。这是一个很大的范围,但即使是两天的“冲刺”来交付可以与利益相关者讨论的东西,也会被视为快速周转时间。

这些项目不仅反映了常见的行业问题,而且旨在弥合初始培训与现实世界之间特定的技能差距。表 1.1 概述了这些项目。

表 1.1 书中八个项目的总结
项目 数据分析技能 章节
分析不同地理区域的客户零售支出 从自由文本中提取结构化数据 2
从电子商务交易中提取独特的客户记录 数据建模 3
在电子商务商店中定义和寻找最佳表现产品 定义指标 4
分析冠状病毒大流行对电影行业的影响 从 PDF 中提取数据 5
通过调查调查开发者对 AI 工具的态度 处理分类数据 6, 7
识别对自行车基础设施的潜在改进 处理时间序列数据 8, 9
探索威尔士房地产市场概念的证明应用 快速原型设计 10, 11
继续另一位分析师的工作,并使用手机活动创建客户细分 在他人工作上进行迭代 客户细分、聚类 12, 13

每个项目都按照以下方式构建:

  • 这一切从高级描述和数据开始。除此之外,你将独立完成。如果你更愿意根据一个相对模糊的分析问题制定行动计划,或者想明确练习这项技能,这一部分就足够阅读了。

  • 接下来是对你如何尝试解决方案的更详细的分步分解。这里不会有代码片段,但如果高级描述让你不知道从哪里开始,这一部分应该能帮助你启动解决方案尝试。

  • 最后,我总会包括一个示例解决方案。关于如何尝试解决问题、你必须做出的假设、在数据限制下你可能会如何改变你的分析问题,以及一个可接受的结果将是什么样的,都会有讨论。在示例解决方案中,我会做出一些可能与你不同的具体假设和决策。这是可以预见的。目标不是让你达到我的解决方案——我的解决方案不会是唯一的解决方案。事实上,我会指出分析可能出现的分歧。目标是练习从问题定义到最小可行答案的过程。

将这一系列现实分析项目视为实践伴侣,帮助你习惯在没有事先了解答案甚至所需工具的情况下深入探索。在学习通过实践项目的过程中,你将

  • 有一个明确的目标,并朝着这个目标努力。

  • 学习必要的技能来获得答案,而不是学习没有实际目标导向的技能。

  • 广度优先学习,而不是深度优先。

使用以结果为导向的方法来处理项目,将使你准备好将这些方法应用于任何项目。这些项目应该激发你继续分析数据,并使用“解决问题”框架进行学习,同时积累新技能并构建一个便于向潜在雇主展示的实用组合。

让我们开始吧!下一步是承担你的第一个项目,为一家假设的英国零售商分析一些客户人口统计数据。我希望你享受这段旅程!

摘要

  • 预期你的正式数据科学培训与现实世界之间存在差距。

  • 实用主义和以结果为导向有助于应对数据分析中固有的不确定性。

  • 通过现实世界的示例练习这种以结果为导向的方法将帮助你

    • 专注于问题解决

    • 提高识别额外考虑因素的能力

    • 培养如何做出分析决策的直觉,以便更快地为利益相关者提供实用解决方案

    • 创建一个真实世界的项目组合,让自己在候选人中脱颖而出

第二章:2 编码地理信息

本章涵盖

  • 如何使用以结果为导向的方法来解决实际问题

  • 在不确定的情况下做出分析决策

现在你已经了解了以结果为导向的方法,让我们将其应用于一个真实的数据科学任务。我们将解决一个现实世界的问题,并应用以结果为导向的方法。这是一个处理其他项目和未来遇到的任何其他项目的模板。

所有项目和数据都可在davidasboth.com/book-code找到,供你尝试。在那里,你可以找到项目所需的数据集以及章节中展示的相同示例解决方案,以 Jupyter 笔记本的形式呈现。

关于 Jupyter 笔记本的注意事项

Jupyter 是一个允许以笔记本形式混合代码和相关文本的开发环境。笔记本文件允许你在文档中直接阅读文本并运行代码。它们在教育和数据分析中很受欢迎。

伴随本书的笔记本收集了每一章的代码片段,并将其整合成一个可运行的单一文档。例如,GitHub 这样的网站甚至允许你在浏览器中查看笔记本,无需安装 Jupyter。请参阅附录,了解如何安装 Python 以运行代码。

对于本章的项目,让我们考察分析师遇到的一个示例任务,乍一看可能足够简单——编码地理信息。

2.1 项目 1:识别客户地理信息

让我们看看我们将从中提取位置信息的项目,以更好地了解我们的客户基础。作为 ProWidget Systems(一家位于英国的 B2B(企业对企业)零售商)的分析师,你被要求报告伦敦客户的支出量与英国其他地区的客户相比。董事会提供了一份包含所有客户地址及其迄今为止总支出的高级数据摘要。他们想知道

  • 哪些英国城市目前服务不足

  • 他们的客户是否主要是伦敦基地

如果这是一个经过净化的教程,你的数据可能有一个名为city的列,你的任务将更多关于按城市分组数据并使用适当的指标进行总结所需的技术步骤。也许你会从所有伦敦客户的总支出中提取数据,并将其与其他英国主要城市或所有city列不是伦敦的数据进行比较。也许你会选择显示分布,以获得更详细的差异图。无论如何,你的选择更多关于获取答案所需的具体技术步骤。

将此与作为真实业务中的分析师回答相同问题进行比较。例如,在这个项目中,可用的数据组织得不够整洁,以至于没有城市列:我们只有一个包含客户地址的客户列,该地址可能包含也可能不包含城市信息。这个项目的地址数据来自英国政府商业和贸易部赞助的执行机构公司注册处。我修改了原始的公开数据,用于这个练习,可在mng.bz/mGxr找到。

在进行任何分析之前,我们需要决定如何识别一个地址与哪个城市相关。我们将遵循第一章中概述的结果导向过程来完成这项工作,并为我们的利益相关者获得一个答案。

真实业务案例:利用地址数据寻找新线索

作为一名数据科学家,我构建的一个数据驱动工具是我们所有客户的地图与所有可能成为潜在客户的英国相关行业的公司名单的结合。销售团队使用这张地图在访问现有客户时识别当地地区的潜在客户。

该项目涉及将我们自己的客户数据与政府官方的公司公开名单相结合,大部分工作是对两个列表中的地址数据进行清理,以便进行比较,这也是本章的主题。

2.1.1 数据字典

理解数据集中有什么的第一步是阅读数据字典,或者如果未提供,则请求一个。你最终可能需要自己编写一个。现实世界与本书项目的一个区别是,我提供了详细的数据字典。不要习惯它!表 2.1 显示了提供数据的数据字典,图 2.1 显示了数据的前几行的快照。

figure

图 2.1 客户地址数据的前几行的快照
表 2.1 客户地址数据集的数据字典
定义
company_id 数据集中每个客户公司的唯一标识符
address 存储客户地址的单个字段
total_spend 到目前为止,这位客户花费的总金额(以英镑计)

现在我们已经明确了问题陈述并看到了可用的数据,是时候开始我们的以结果为导向的过程,以找到解决方案。

2.2 一个示例解决方案:寻找伦敦

在本节中,我将深入一个示例解决方案,重点关注结果驱动方法的步骤以及解决问题的细节。在继续之前,你可能希望尝试这个问题,或者使用本章来了解其余项目的结构。至于我提供的多数解决方案,代码本身将用 Python 编写,主要使用pandas库。虽然将使用代码片段来解释示例解决方案,但我将重点讨论概念解决方案,而不是代码的具体细节。解决方案将分为三个部分:设置问题和数据,创建解决方案的第一迭代,以及审查工作并决定下一步。

2.2.1 为成功做好准备

figure

第一步是确保我们理解了问题。在这种情况下,我们知道我们需要使用我们的客户地址数据来计算英国的总支出,并找出伦敦客户与该国其他地区相比的答案。如果我们遇到多个指标,例如客户进行的交易数量或他们作为客户的时长,我们需要与我们的利益相关者澄清问题的目的,以便我们知道应该关注哪些指标。

在这种情况下,提供的数据不包含这种歧义;很明显,我们关注的是两个预定义地理区域的支出模式。然而,如果我们的利益相关者询问我们哪些地区普遍服务不足,我们也会寻求了解是否应该查看城市层面的数据或不同粒度的数据。

figure

任何分析中的关键步骤是考虑我们将走向何方。最小可行答案将采取什么形式?在这个例子中,我们的利益相关者有两个问题:

  • 不同城市是否服务不足? 这需要我们按城市计算总客户支出,并找出客户支出最低的城市。

  • 伦敦与其他英国地区相比如何? 这可以从第一个答案的输出中得到回答。

为了存在一个最小可行答案,我们需要在我们的数据中添加一个城市列,我们将从地址中提取它。这将使我们能够按城市细分支出,这意味着我们可以将伦敦的行与表格中的其余部分进行比较。至于最终输出,表格或条形图对于这两种情况都足够了。这看起来可能很明显,但了解我们输出的确切格式将引导我们找到相关的解决方案,即包含每个城市一行及其相关总支出金额的表格。

*figure

为了解决任何分析问题,我们需要知道回答该问题需要哪些数据。为了获得城市级别的客户数据,我们需要知道每个客户属于哪个城市的数据。这要求每个客户记录都有一个城市列,或者至少有一些地址数据。我们知道在这种情况下,已经提供了地址字段,所以我们有足够的数据尝试解决这个问题,即使我们不确定在这个阶段是否可以得到令人满意的答案。在这个步骤中,我们还决定可能需要的额外数据源。在这种情况下,可能需要提高我们地址数据的准确性。我们的第一次迭代通常应该专注于我们已有的数据,而额外的数据源可以在未来的迭代中考虑。

图

获取数据的步骤不应被低估。整个数据科学项目可能因为数据科学团队从未收到任何可用的数据来分析而失败。获取数据的障碍不是技术性的,而是组织性的,这是我们不会在现实世界中模拟的一个方面。在整个项目中,你不需要自己寻找任何数据;它将被提供。它可能不会总是容易清理,但你不会花时间去给组织中的某个人发邮件,希望他们的善意能发送给你数据提取!

2.2.2 创建解决方案的第一迭代

图

到目前为止,我们已经建立了问题陈述并获得了所有必要的数据。现在是时候在提取每个地址的城市成分和按城市汇总客户消费数据之前探索数据集了。让我们先导入必要的库并读取数据:

import pandas as pd
customers = pd.read_csv("./data/addresses.csv")
print(customers.shape)

输出是 (100000, 3),这意味着我们总共有 100,000 行数据,以及我们在数据字典中看到的三列。在提取任何城市数据之前,我们应该检查缺失值。

调查缺失值

根据你选择的工具,尝试从空地址中提取数据可能会产生错误。以下代码片段的输出显示在图 2.2 中:

customers.isnull().sum()

图

图 2.2 展示每列缺失值的数量

我们可以看到有 968 个缺失的地址,这大约是行总数的 1%。由于我们无法仅从提供的数据中知道那些缺失客户的地址,我们可以安全地删除这些行:

customers = customers.dropna(subset=["address"])

可以接受的行缺失百分比将取决于上下文,但由于缺失关键信息而丢失 1%是可以接受的。如果我们有 10%的客户地址缺失,我们可能想调查一下原因。

然而,这是一个分析可能分叉的点。缺失的地址数据可以保留并简单地分类为“其他”,或者如果我们有更多与客户相关的数据,我们可能选择花时间在其他地方查找客户的地址,以获得更完整的数据集。

在分析中,有许多地方没有明确的正确决策,只有基于不同假设的不同决策。正因为如此,你的分析将在多个地方与我分叉。图 2.3 通过图表展示了可能的路径分支,我将大量使用这个图表来重申这一点。

figure

图 2.3 分析的第一步及其替代路径的可视化

形状有两种类型:步骤和决策。步骤代表在分析中需要执行的顺序任务。这些步骤来自原始行动计划,但也可能受到我们在数据中发现的信息的影响。决策代表分析可能因假设和选择而分叉的地方。你的分析不一定包含与我相同的步骤或决策。

当我们在探索我们的列时,我们还应该检查我们的total_spend列是否包含任何奇怪值,例如负值。以下代码片段的输出显示在图 2.4 中:

customers["total_spend"].describe()

figure

图 2.4 total_spend列的汇总统计

如上图所示,数值范围从 0 到略低于 12,000 英镑,没有负值。现在我们已经看到了我们的数据,我们需要决定一个方法来提取有关城市的信息。让我们回顾一下我们的两个利益相关者问题:“不同的城市是否被服务不足?”以及“伦敦与其他英国地区相比如何?”第二个问题是第一个问题的子集。也就是说,一旦你有了按城市划分的客户消费数据集,比较伦敦与其他城市就很容易了。解决一个问题意味着解决另一个问题,所以如果我们首先专注于识别伦敦的地址,我们就可以找到地址表示的细微差别和边缘情况。

从地址中提取城市列

在决定方法之前,我们应该查看一些样本地址。以下代码打印出前五个地址,其输出显示在图 2.5 中:

for address in customers["address"].head():
    print(address, "\n")

figure

图 2.5 五个样本地址

我们已经可以注意到一些模式,以及一些潜在的陷阱。从我们的有限样本来看,地址似乎以邮编结束,但有时伦敦地址包含一行英格兰,有时则不包含。这意味着我们不能依赖于查看地址的特定行来给我们城市的信息,这是有用的信息。

那么,寻找字符串“London”来识别伦敦的客户怎么样?这会足够吗?可能不会。首先,你可能会包括住在其他城镇的“London Road”上的人。检查数据,我们可以看到地址行由逗号和换行符分隔,因此寻找 "LONDON," 可以缓解这个特定问题。注意搜索字符串中的额外逗号。我们的规则可以简单地是以下这样:如果一个地址的某一行是“London”,那么这个地址就是伦敦的地址。在我们的样本中,所有地址都是大写,但我们不应假设所有 100,000 行都会是这样,因此我们应该确保它们都是大写的。为了比较我们的清洗后的地址数据与原始数据,我们将创建一个新列来存储清洗后的版本。通常,将数据保留在原始形式以便需要时参考是一个好主意:

customers["address_clean"] = customers["address"].str.upper()

现在我们已经确保了大小写的一致性,我们可以调查寻找 "LONDON""LONDON," 之间的差异:

len(customers[customers["address_clean"].str.contains("LONDON")])
len(customers[customers["address_clean"].str.contains("LONDON,")])

这些代码行的输出分别是 21,76820,831,这意味着当我们添加逗号时,将近 1,000 行不再被选中。这些是包含单词“London”的地址,但我们假设实际上它们并不在伦敦市(例如,位于伦敦路的地址)。我们已经从我们的样本地址中看到,我们不能依赖于行的位置来决定城市成分的位置。然而,地址结构可能只有有限的数量。让我们看看有多少地址由多少行组成。此代码的输出如图 2.6 所示:

customers["address_lines"] = (
    customers["address_clean"]
    .str.split(",\n")                     #1
    .apply(len)                       #2
)
customers["address_lines"].value_counts().sort_index()    #3

1 将地址拆分为子字符串列表(即分离行)

2 计算每个列表的长度(即地址中的行数)

3 现在计算由多少行组成的地址数量

figure

图 2.6 地址长度的分布

我们可以观察到,一些地址只有一行或两行,而一些地址多达六行。理论上,如果每三行、四行、五行等地址都是一致的,我们就可以制定一个规则,从每个地址中提取城市的方式略有不同。我们还应该检查一些这些较短的地址,看看它们是什么样子。此代码的输出分别如图 2.7 和 2.8 所示:

print(customers.loc[customers["address_lines"] == 1, "address_clean"])
print((
    customers[customers["address_lines"] == 2]
    .sample(5, random_state=42)                  #1
    ["address_clean"])
)

1 查看只有两行地址的五个随机行。random_state 参数确保我们每次都能得到相同的结果,以保证可重复性。

figure

图 2.7 我们数据中所有的单行地址

figure

图 2.8 两行地址数据的五个样本行

这些图显示了数据的可变性。有些行只是一个城市,福尔柯克;有些行完全缺失地址,指引我们到一个父注册处;我们还有邮政信箱地址。我们不太可能仅基于地址行中的位置制定出提取城市的规则,而不编写大量的定制代码。

让我们再次提醒自己我们的目标:我们的直接任务是创建一个 城市 列,我们现在必须决定如何进行。一个选项是采用我们的伦敦查找示例,并通过在 地址 列中显式查找城市名称来扩展它。这需要一份英国城市的综合列表,这并不是一个不可能的任务。这样的列表是存在的——例如,mng.bz/5gKB 上的列表是由英国政府提供的。在这种情况下,我们仍然需要决定如何处理位于这些城市之外地址的情况。我们是否简单地将其标记为“其他”?对于第一次迭代,这可能足够了,因为问题特别询问的是城市,而不是镇或村庄。

另一个选项是使用每个地址的邮政编码部分,并在国家邮政编码数据库中进行查找,我们必须获取并确保我们有权限使用它。这可能是一种更准确的方法,但需要额外的工作,例如首先识别每个地址的邮政编码部分。

通常,在我们的第一次迭代中,我们应该尽量减少工作量,因此我们将尝试使用英国政府提供的英国城市列表。网页包含每个英国国家的城市列表,其中一部分如图 2.9 所示。

图

图 2.9 英国政府网页上列出的所有英国城市摘录

有多种方法将这些信息提取成代码友好的格式。最简单的一种是将网站上的项目符号直接复制粘贴到 Excel 中,这样每个城市就单独占一行,然后我们可以在代码中导入并清理这些数据。最快的选项并不总是编写代码!然而,为了确保完全可重复性,你可能希望自动化这一步骤。由于我们预计英国城市的列表不会经常变化,手动获取这些数据在这里是合适的。这些原始数据作为单独的文件提供,文件名为 cities.csv。这些数据的前几行如图 2.10 所示:

cities = pd.read_csv("./data/cities.csv", header=None, names=["city"])
cities.head()

图

图 2.10 城市数据的前几行

在使用它作为城镇的最终列表之前,显然需要清理一些数据。首先,国家标题被包含在数据中的行中,所以需要移除值EnglandScotlandWalesNorthern Ireland。然后,需要修剪尾随的星号*字符,剩余的城镇名应该大写以匹配我们的地址数据。图 2.11 显示了我们的最终、清洗后的城镇列表样本:

countries_to_remove = ["England", "Scotland", "Wales", "Northern Ireland"]

print(len(cities))
cities_to_remove = cities[cities["city"].isin(countries_to_remove)].index
cities = cities.drop(index=cities_to_remove)
print(len(cities))

cities["city"] = cities["city"].str.replace("*", "", regex=False)

cities["city"] = cities["city"].str.upper()
cities.head()

图

图 2.11 我们清洗后的、确定的城镇列表样本

现在我们有了确定的城镇列表,我们可以用它来创建city列。图 2.12 显示了我们所做的工作以及我们最近步骤中的替代方案。

图

图 2.12 分析到目前为止的两个主要步骤后的分析图

创建城镇列

如果在地址中找到某个城市名和额外的逗号,我们可以使用我们的城镇列表来标记一个特定城市的地址。以图 2.11 为例,我们将假设包含子字符串“BATH,”的地址是巴斯市的地址,因此city列中的值将是“BATH”。任何新创建的city列没有值,地址中没有找到任何城市名的地方,将被归类为“OTHER”。以下代码实现了这一点,图 2.13 显示了我们数据的最新状态:

for city in cities["city"].values:
    customers.loc[customers["address_clean"].str.contains(f"\n{city},"),
↪ "city"] = city

customers["city"] = customers["city"].fillna("OTHER")

customers.head()

图

图 2.13 带有新添加的city列的数据摘录

现在我们有了新的city列,我们需要探索它,看看我们的客户在哪些城市,以及根据他们的地址,我们无法将多少客户分配到城市的比例。

探索新的城镇列

根据图 2.13,看起来我们已经正确地将前五行中的伦敦和布里斯托尔地址归类,并将其余的归入“其他”类别。现在我们可以通过计算每个城市中出现的客户数量来进行我们的第一次分析,根据我们的分类。让我们看看图 2.14 中显示的前 20 个:

customers["city"].value_counts().head(20)

图

图 2.14 按客户数量排名前 20 的城市

我们的数据中超过一半属于“其他”类别,这意味着我们的一半客户群是在大城市之外建立的。这是一个需要向我们的利益相关者传达的重要见解。让我们看看这个类别中的一些地址,如图 2.15 所示:

sample_other = customers[customers["city"] == "OTHER"]
↪ .sample(5, random_state=42)
for address in sample_other["address_clean"].values:
    print(address, "\n")

图

图 2.15 被归类为“其他”的城镇的地址样本

其中一些地址与城镇相关,但在特威肯汉姆有一个地址,它是伦敦的一个郊区。虽然这个地址没有包含“伦敦”这个词,但这位客户应该被归类为伦敦本地。我们已经开始看到我们选择的方法的一些不足之处。在这个阶段,我们将它们记下来,并可能在未来的迭代中解决它们。

注意:除非你了解英国地理,否则你可能错过这些没有“London”一词的伦敦地址实例。这突出了领域知识对分析师的重要性以及为什么你应该与领域专家密切合作。

此时的一个合理性检查是查看政府数据和我们的标记地址数据中有多少独特的城市。根据业务,我们可能假设我们在每个主要英国城市至少有一个客户,我们可以验证这一点。Python 的一个技巧是创建政府城市列表和我们的新city列中独特城市列表的唯一集合,并从其中一个集合中减去另一个。这将给出两个列表之间的差异,即出现在政府列表中但不在我们的地址数据中的城市:

set(cities["city"]) - set(customers["city"])     #1

1 在 Python 中,减去集合意味着找出一个列表中不在另一个列表中的项目。

此代码的输出是字符串{'KINGSTON-UPON-HULL'},这告诉我们城市 Kingston-upon-Hull 不在我们的客户地址数据的city列中。这可能意味着我们那里没有客户,考虑到其人口只有大约 25 万,这是可能的,或者还有其他情况。该城市通常简称为“Hull”,这又是一个将特定领域知识应用于问题的例子,因此让我们在我们的地址数据中查找它:

customers[customers["address_clean"].str.contains("\nHULL,")]

此代码的输出告诉我们有 284 条相关记录。我们可以手动更新我们的city列以解决这个问题:

customers.loc[customers["address_clean"].str.contains("\nHULL,"),
↪ "city"] = "HULL"

让我们再次回顾到目前为止的分析。我们已完成的工作和替代步骤如图 2.16 所示。

图

图 2.16 我们分析目前的进度

现在我们可以查看使用新创建的city列的城市消费数据了。

分析按城市消费

严谨的工作是将数据整理成正确的格式;分析本身只是一个简单的分组和汇总。输出图表如图 2.17 所示:

from matplotlib.ticker import FuncFormatter
import matplotlib.pyplot as plt

def millions(x, pos):                #1
    return '£%1.1fM' % (x * 1e-6)

formatter = FuncFormatter(millions)

fig, axis = plt.subplots()

top_20_spend = (
    customers
    .groupby("city")
    ["total_spend"].sum()
    .sort_values(ascending=False)      #2
    .head(20)
    .sort_values(ascending=True)      #3
)

top_20_spend.plot.barh(ax=axis)

axis.xaxis.set_major_formatter(formatter)
axis.set(
    title="Total customer spend by city",
    xlabel="Total spend"
)

plt.show()

1 定义一个函数以显示百万为单位的数据

2 按总消费额降序排序以获取前 20 位最高消费者(分析所需)

3 现在按相反方向排序,使我们的水平条形图顶部显示最高数字(Python 绘图所需)

图

图 2.17 各城市总消费额

很明显,“其他”类别占主导地位。正如我们之前看到的,我们的客户同样可能在主要城市之外。这部分是由于英国对城市的定义。只有 76 个被官方称为“城市”,但有许多人口众多的“镇”。城镇与城市之间的这种区别部分解释了为什么“其他”类别如此之大。

另一个观察结果是,伦敦确实在客户消费和客户数量方面都是最高的城市。在消费方面排名第二和第三的城市,曼彻斯特和伯明翰,也是人口最多的城市。在这张图表和图 2.14 中我们的客户量计算中,顶级城市通常与人口最多的城市相对应。利兹可能低于你根据其人口预期的水平,但没有任何理由说客户消费数字应该与人口完美相关。

我们最后的调查是对伦敦与其他英国地区的比较。我们可以将其理解为“除了伦敦以外的所有英国”或“除了伦敦以外的所有主要城市”。我们应该计算这两个数字并决定如何报告我们的发现:

print("Total spend for all customers:")
print(customers["total_spend"].sum())

print("Total spend for London customers:")
print(customers.loc[customers["city"] == "LONDON", "total_spend"].sum())

print("Total spend outside London:")
print(customers.loc[customers["city"] != "LONDON", "total_spend"].sum())

print("Total spend outside London (excluding OTHER):")
print(customers.loc[customers["city"].isin(["LONDON", "OTHER"]) == False,
↪ "total_spend"].sum())

这段代码的输出告诉我们,我们客户的总消费为 4.9 亿英镑。这分解为伦敦客户的 1.03 亿英镑和英国其他地区的 3.87 亿英镑。如果我们看伦敦以外的所有主要城市,总数为 1.19 亿英镑。也就是说,如果我们将伦敦与其他城市比较,我们可以看到我们的伦敦客户产生的收入几乎与其他所有主要城市之和相当。确实看起来我们的客户基础是以伦敦为中心的。

这为我们提供了最小可行答案,但我们还没有完全回答某些城市是否被忽视的问题。图 2.17 显示,最大的城市产生了最高的客户消费。要了解一个城市是否被忽视,我们需要知道更多关于我们基于什么来评估的信息。这需要与我们的利益相关者进行对话,了解他们是否遗漏了他们在图 2.17 中期望看到的高消费城市。在我们继续之前,让我们回顾一下我们在这次分析中采取的所有步骤(图 2.18)。

figure

图 2.18 最终分析步骤的可视化

在这个过程的最后,我们得到了我们的最小可行答案,并且我们准备将其带给我们的利益相关者。

2.2.3 审查和未来步骤

figure

在清理我们的数据并提取城市信息后,我们得到了对分析问题的答案。我们的客户在大城市花费最多,因为大城市通常有更多的客户,而伦敦产生的收入几乎与其他所有城市之和相当。这个见解,结合图 2.14 中的表格和图 2.17 中的图表,可以放在一两个幻灯片上向我们的利益相关者展示。

在展示时,我们应该清楚地说明我们分析的限制;即,并非所有地址都采用允许我们正确提取城市的格式,至少在我们选择的方法中是这样。另一个限制是我们使用了政府的城市名单,该名单排除了我们的利益相关者可能感兴趣的较大城镇。我们会向我们的利益相关者明确表示,可以进行进一步的工作,以使我们的城市级别数据更加准确。然而,我们需要了解在可能不会改变整体结果的情况下,花费更多时间来获得更准确的数据的业务价值。

figure

在展示我们的结果后,我们的利益相关者可能希望我们继续分析并得到一个更全面的答案。我们如何改进我们的解决方案?也许,正如我们讨论的那样,我们可以通过识别邮编来识别地址所指的城市或城镇。嗯,为此,我们需要一个全面的英国邮编列表,假设“客户地址”字段中的所有值都包含邮编,以及假设我们每次都能找到邮编。另一个选择是将我们当前的城镇名单扩展到包括一定人口以上的城镇。然而,这并不能解决有时城市或城镇的名称不在地址中的问题。

另一个选择是将这些地址发送到第三方地理编码系统,例如 Google Maps API,该系统将为我们提供的地址找到最佳匹配项,并且结果数据将包含一个我们可以使用的城镇/城市字段。然而,这个特定的选项引发了隐私问题。我们是否希望将所有客户的地址发送给第三方?我们甚至是否有权这样做?

最后,我们可以通过人口统计数据或甚至每个城市或城镇的人口统计数据来增强我们的数据。这可以帮助我们了解是否存在客户在根据其城市人口预期花费较少的城市,因此受到服务不足的情况。这是我们利益相关者最初的担忧之一,这也是进一步解决这一问题的方法之一。

你可以开始看到解决一个真实商业问题涉及多少额外的考虑。这就是为什么尽快找到一个可行的解决方案至关重要,这样这些额外的可能性只有在这样做具有实际商业价值时才予以考虑。任何进一步工作的价值都应在与关键利益相关者的合作中决定。

活动:使用这些数据进一步的项目想法

在每个项目章节中,我鼓励你考虑其他可能的研究问题,用这些数据来回答,这些问题与特定章节项目无关。这是一个练习,帮助你使你的解决方案真正属于你,并使其在作品集中脱颖而出。以下是一些帮助你开始的想法:

  • 客户消费数据在不同粒度级别上看起来是什么样子?你可能需要深入到次城市级别,例如区,或者找出如何将属于同一地理区域(例如,西南英格兰)的地址分组的方法。

  • 你能否识别商业地址,并考虑比较拥有私人地址的客户与拥有商业地址的客户?

  • 用人口数据或其他人口统计信息增强你的地理分析。是否存在任何模式,例如一个地区的财富与客户消费金额之间的关系?你也可以计算每个城市的“人均消费”并查看它在整个国家中是否有所变化。

2.3 如何使用本书的其余部分

本章介绍了实践中以结果为导向的方法的具体示例以及所有项目的格式。在你整个职业生涯中,每个项目都将要求你

  • 确保你理解了问题

  • 思考你正在努力实现的目标

  • 考虑你是否拥有正确的数据

  • 识别在展示你的发现时需要标记的注意事项

  • 记录在得到最小可行答案后可能进行的进一步工作

本书中的每个项目都针对你在现实世界的分析项目中会遇到的不同主题,并且它们都将遵循本章中相同的流程。

摘要

  • 采用以结果为导向的方法有助于集中精力解决具体问题。

  • 以结果为导向的方法的一部分是确保在开始分析之前你理解了问题。

  • 在开始分析之前就设想最终结果可以创建一个目标去努力实现。

  • 提取自由格式地址中的城市部分有多个可能的方法,每种方法都有其优缺点。

  • 分析结果将根据所做的选择和假设而有所不同;因此,可能得到不同的,但仍然正确的结果。

  • 即使是看似较小的任务,例如从地址数据中提取城市信息,也能通过帮助关注有价值的成果而受益。*

第三章:3 数据建模

本章涵盖

  • 将数据建模作为基本分析活动

  • 如何从原始数据中定义业务实体

  • 如何构建数据模型以最好地适应分析问题

作为一名分析师,您将发现自己反复将相同的逻辑应用于原始数据。例如,每次计算收入时,您可能需要记住要删除部门之间的内部资金转移。或者当您查看客户支出时,您可能需要排除某个客户,因为他们运营方式不同。每当这些业务规则需要不断应用以确保数据准确时,这是一个构建数据模型的好机会。

数据模型是从经过清洗的原始数据中创建的数据集,其中内置了特定的业务规则。创建可重用的数据模型将在未来节省您的时间和维护烦恼。数据建模还迫使您深入思考您或您的利益相关者的问题,这将导致更有价值的答案。

真实业务案例:客户去重

曾经,在我从事行业工作时,我花费了几个月的时间在一个客户去重项目上。我们想要跟踪客户随时间的变化,但我们的客户分布在多个数据库中。去重他们不是一个简单任务,特别是在某些数据库中,客户以公司名称出现,例如“西南汽车”,而在其他数据库中,他们被记录为个人,例如“约翰·史密斯”,没有任何关于他们工作的公司的信息。

最后,我们的解决方案涉及文本相似性算法来找到“西南汽车”在一个数据库中存在于另一个数据库中的“西南汽车有限公司”的情况。我们还使用了图论来将客户连接到我们公司的网络中。这些是针对看似简单任务(计数客户)的高级算法。实体解析问题无处不在,这就是为什么本章探讨了该主题,并让您在一个真实的问题上练习。

在本章中,我们将回顾数据建模的基本原理和重要性,并使用一个真实世界的项目练习将原始数据转换为可重用的数据模型。

3.1 数据建模的重要性

数据建模是分析工作流程的基础步骤。它是将原始数据映射到特定业务实体,并创建新的数据模型的过程。我们可以将其视为将原始状态的数据转换为更有用的形式,我们称之为信息。数据分析随后是将这些信息转换为洞察的过程。这个中间步骤是必需的,因为在原始形式中,数据通常还没有准备好进行分析。图 3.1 显示了数据建模在数据科学工作流程的抽象和具体版本中的位置。

图 3.1

图 3.1 数据建模与分析在数据科学流程中的映射

您的数据模型应该编码任何将原始数据转换为适合分析所需的业务逻辑。如果您总是需要记住从原始数据中过滤掉某些行,您应该有一个中间数据模型,其中已经应用了该过滤器。对于您的业务,“流失客户”意味着什么?是有人在一定时间内没有购买任何东西吗?或者可能是有人很久没有登录到您的平台?无论这个定义是什么,它都应该编码在数据模型中。

创建数据模型增加了透明度,因为有一个单一的地方可以查找客户、车辆或购买事件是如何定义的。所有其他分析都应该使用这些中间模型进行,而不是原始数据。另一个好处是这种更干净的数据模型可以被数据熟练的利益相关者直接使用,从而实现自助服务。例如,Tableau 和 Power BI 这样的商业智能工具允许高级用户创建自己的报告。如果使用集中式数据模型来完成这项工作,分析错误的可能性就会降低。

作为分析师,我们应该寻找机会通过在数据模型中编码来标准化业务逻辑。这些不必在技术上复杂,因为它们可以简单地是我们数据库中的附加表。让我们看看数据建模中涉及的一些任务,我们将在项目中练习这些任务。

3.1.1 常见的数据建模任务

数据建模通常涉及以下任务的组合

  • 重复的数据清理任务,例如修复日期格式或将文本列转换为它们的数值等效。

  • 定义业务实体、概念和活动。

  • 去重源数据。

  • 重新结构化原始数据,使其更适合于它旨在回答的分析问题。这可能涉及在宽数据或长数据之间做出选择,我们将在本节稍后讨论。

  • 放大或缩小,改变不同分析问题的粒度级别。

这些都是您不希望在每次进行某些分析时都执行的任务。它们应该只做一次,并且输出应该被捕获在适当的数据模型中。

就术语达成一致

作为初级分析师,您可能会进入一个您不熟悉的行业。重要的是要提出问题以澄清术语,因为即使是像“客户”这样的日常术语也可能有模糊的含义。客户是指一个人还是一个组织?如果您的业务同时涉及两者怎么办?数据建模过程的一部分是定义这些术语,以便它们可以编码在数据表中。

注意:在定义方面,您不能孤立地工作;关于哪些概念具体含义的决定需要与您的利益相关者合作进行。

处理重复数据

你将要处理的数据不可避免地会包含一些重复,这可能以数据行的重复或多个系统中的重复记录的形式出现。如果你在汽车行业工作,你可能需要花费相当多的时间来确定一个数据库中的“John Smith Motors”是否与另一个数据库中的“JS Motors”是同一个客户。在数据建模阶段投入时间进行这种协调是值得的。

另一个重要的数据建模任务是决定你的数据结构,例如数据应该以宽格式还是长格式存储。

宽格式与长格式数据

在许多情况下,你的数据将包含每行一个实体,例如一个客户。每一行代表一个客户,每一列代表该客户的属性或属性,例如他们的名字、年龄、部门等等。这被称为宽格式,因为随着测量数量的增长,数据将增加额外的列。

相比之下,长格式数据是指一行代表一个实体的单一测量值。这意味着一个实体,例如一个客户,将需要多行。当关于实体的新测量值被添加时,数据将增加额外的行。

让我们用一个具体的例子来说明。假设你为一家体育数据分析公司工作,并希望分析赢得重大比赛的体育队伍的因素。表 3.1 显示了你的足球比赛结果数据集。

表 3.1 宽格式的足球比赛结果
匹配 ID 日期 比赛 轮次 主场球队 客场球队 主场进球 客场进球
1 2014-07-04 2014 年世界杯 半决赛 法国 德国 0 1
2 2014-07-04 2014 年世界杯 半决赛 巴西 哥伦比亚 2 1
3 2014-07-05 2014 年世界杯 半决赛 阿根廷 比利时 1 0

这是一种宽格式数据,因为每一行代表一个实体,在这种情况下,是一场比赛。通过一些增强(例如,添加“赢家”列),这个数据集将允许轻松分析诸如“哪个国家在世界杯上赢得的比赛最多?”等问题。

然而,如果有人问,“哪个国家参加了最多的世界杯比赛?”这个问题就复杂了,因为每一行的粒度级别是每场比赛一行,因此我们必须考虑“主场球队”和“客场球队”这两列。为了更容易回答这个问题,我们需要的是每行一个参与者的数据。我们可以考虑创建一个类似于表 3.2 的长格式数据版本。

表 3.2 以长格式呈现的相同足球比赛结果,每行代表一个比赛参与者
匹配 ID 日期 比赛 轮次 球队 主场或客场? 进球数
1 2014-07-04 2014 年世界杯 半决赛 法国 主场 0
1 2014-07-04 2014 年世界杯 半决赛 德国 客场 1
2 2014-07-04 世界杯 2014 半决赛 巴西 主场 2
2 2014-07-04 世界杯 2014 半决赛 哥伦比亚 客场 1
3 2014-07-05 世界杯 2014 半决赛 阿根廷 主场 1
3 2014-07-05 世界杯 2014 半决赛 比利时 客场 0

这现在变成了长数据,因为行不代表唯一的实体。这个表格编码了相同的信息,但每个比赛都是故意重复的。从这个表格中,更容易只关注“队伍”列来找到拥有最多世界杯比赛的队伍。这种格式的缺点是,我们不能简单地计数行数来找到统计数据,比如在世界杯上进行的比赛数量,因为我们将会重复计数。

无论是宽格式还是长格式,没有一个比另一个更好;它们之间的选择取决于你使用数据试图回答的问题。评估哪种格式最适合你的分析问题是数据建模的本质。

确定合适的粒度级别

就像足球示例一样,你将遇到分析粒度不合适的数据集。美国选举结果数据可能是在县一级,但你可能对个别候选人感兴趣。拥有候选人级别的数据模型将有助于更快地回答针对候选人的特定问题。信息是相同的;只是存储的格式更适合你的分析问题。

在开始这个项目时,先问自己,“最终数据模型的结构应该是什么?”朝着这个目标(以结果为导向的方式!)将指导你需要采取的具体步骤。

3.2 项目 2:你的客户是谁?

让我们来看看我们的项目,在这个项目中,我们将从一系列零售交易中提取客户数据库。我们将查看问题陈述,这是我们利益相关者用自己的话想要实现的目标。我提供了可用数据的概述,并讨论了示例解决方案的一些技术细节。阅读第 3.2 节就足够开始,但你可能会发现第 3.3 节有助于了解在这个场景中如何应用以结果为导向的方法。

数据可在davidasboth.com/book-code找到。你将找到可以尝试项目的数据集,以及以 Jupyter 笔记本形式的示例解决方案。

3.2.1 问题陈述

在这个例子中,你被雇佣到 Ebuy Emporium,一家新的电子商务初创公司。他们已经运营了一个月,并且取得了意外的成功。他们开始对他们的客户群产生浓厚的兴趣。他们的客户是谁?他们买了什么?是什么驱使他们购买?然而,在他们对任何严肃的分析之前,他们需要能够计算他们的客户数量,这比预期的要困难得多。一个问题是有多个客户数据来源,它们是

  • 电子商务平台的客户数据库,当客户在线注册账户时,客户详细信息会被记录。这是大多数客户详细信息应该被找到的地方。

  • 内部客户关系管理(CRM)系统,当客户通过电话购买或以其他方式成为客户(除了使用注册账户在线购买)时,客户详细信息会被记录。

  • 原始交易数据,我们在此之后将称之为“购买”或“销售”,其中还包含作为访客进行的购买,这意味着在购买时没有明确创建客户记录。

注意:原始交易数据由 REES46 提供 (mng.bz/6eZo),并增加了来自欧盟研究与方法论合作项目(CROS)记录链接培训计划的虚构客户数据 (mng.bz/oKad)。感谢数据集所有者允许重新使用原始源数据。

另一个问题在于现有数据源可能不是相互排斥的——它们之间可能存在重叠。几乎可以肯定存在一些重复,要么是因为同一客户在多个系统中输入了他们的详细信息,要么是因为他们以访客身份和注册账户身份都进行了购买。重复账户可能不包含完全相同的信息;可能存在拼写错误或误拼。正是这些复杂情况使得初创公司需要分析师的帮助来回答他们的问题:“我们的客户是谁?”

3.2.2 数据字典

表 3.3 和 3.4 展示了三个数据源的数据字典,图 3.2 和 3.3 展示了样本数据。

表 3.3 “购买”数据集的数据字典
定义
event_time 购买发生的确切日期和时间。
product_id 购买产品的唯一标识符。
category_id 购买产品特定类别的唯一标识符。
category_code 购买产品的广泛类别。在层次结构中,类别代码包含多个类别 ID,且一个 category_id 只应与一个 category_code 相关联。
brand 购买物品的品牌(如果适用)。
price 购买物品的价格(以美元计)。
session_id 购买会话的唯一标识符。如果在交易中购买了多个项目,则每个项目在表中将有一行,并且这些行将共享一个session_id
customer_id 如果客户使用注册账户进行购买,则客户的唯一标识符。对于访客购买,此值将不存在。
guest_first_name 如果作为访客进行购买,提供的名字。对于使用注册账户进行的购买,此值将不存在。
guest_surname 如果作为访客进行购买,提供的姓氏。对于使用注册账户进行的购买,此值将不存在。
guest_postcode 如果作为访客进行购买,提供的邮编。对于使用注册账户进行的购买,此值将不存在。

figure

图 3.2 购买数据集的快照
表 3.4 CRM 和客户数据集的数据字典,它们具有相同的结构

| 列 | 定义 |
| --- |
| customer_id | 本系统中客户的唯一标识符 |
| first_name | 客户的姓氏 |
| surname | 客户的姓氏 |
| postcode | 客户的邮政编码 |
| age | 客户的年龄,以年为单位 |

figure

图 3.3 客户数据的快照

备注:重要的是要记住,数据字典指的是关于每个列中存在哪些数据的假设。在探索性数据分析阶段验证这些假设是良好的实践。例如,购买数据中提供的客户 ID 是否总是与客户数据库中的一个记录匹配?

在这个案例中,数据字典是自解释的,但处理新数据集的第一步始终是确保我们已经阅读了任何相关的文档。

3.2.3 预期结果

本项目的输出应是一个代表您的客户数据模型的单一数据集——这是您对初创公司目前拥有的整个客户基础的最好估计。数据将来自提供的三个来源,您需要相应地进行整合和去重。您需要根据数据集中可用的列来决定数据模型的结构。此数据模型应结构化,以便所有定义客户的逻辑都已经就绪,回答“我们有多少客户?”的问题应该像计数行一样简单。

你并不追求一个唯一的正确解决方案,也没有一个可以用来验证你答案的基准事实。这主要是因为分析中包含了很多模糊性,不同的分析师会做出不同的假设并得出不同的结论,部分也因为这类任务在现实世界中通常没有可以用来验证答案的答案。成为一名优秀分析师的部分是接受持续的不确定性和模糊性,并对可能永远不完整的答案感到舒适。重要的是能够提供你的利益相关者可以使用答案。

3.2.4 必需的工具

虽然项目是技术中立的,但在这个示例解决方案中,我使用 Python 库pandas来操作数据集,并使用numpy进行数值函数。我还介绍了用于实体解析的recordlinkage库。我将代码片段保持在最小,并专注于概念解决方案,但完整的解决方案以 Jupyter 笔记本的形式呈现。这是一个用于在单一文档中展示代码、数据和文本的工具,使得分享你的发现以及背后的方法变得容易。只要你使用的工具满足以下标准,即它们能够

  • 从 CSV 文件中加载包含数万行数据的集合

  • 创建新的列并操作现有的列

  • 将数据集合并在一起

  • 执行基本分析任务,如排序、分组和重塑数据

3.3 制定客户数据建模的方法规划

让我们使用以结果为导向的方法将问题分解为其逻辑组成部分。这将在我们开始工作之前,让我们对问题有更深入的理解。我们还将明确决定我们不想做的事情,也就是说,我们将找出哪些问题的特征对于第一次迭代不是必要的。

3.3.1 将结果驱动过程应用于数据建模

figure

首先,我们需要理解问题。在这种情况下,问题模糊地是“我们的客户是谁?”这通常需要一些反驳以澄清。在这个例子中,无论关于客户的实际分析问题是什么,数据建模步骤对于回答它是基本的。我们必须首先从三个数据源中整合客户数据。

TIP  我们可以考虑添加哪些额外数据来更好地满足分析问题的需求。我们知道我们希望每个客户对应一行,但我们还不知道客户购买历史的总结是否会是一个有用的补充。在这种情况下,我们不希望预先花费时间在我们的数据模型中添加信息,因为我们认为将来可能会有人要求它。

*figure

我们的产品最终形态非常明确,但具体来说,从最终结果开始意味着对最终数据模型的结构有一个概念,其存在将使我们能够计数客户以产生我们的最小可行答案。我们知道它最重要的属性之一应该是它包含每名客户的一行。这是我们追求的粒度级别。因此,我们添加的任何购买数据都需要汇总到客户级别;我们无法包括客户购买的个别产品,但可以包括他们的总消费、他们首次注册的日期、他们做出的独特购买数量等等。

另一个展望最终结果的角度是确定我们最终数据模型的模式。我们的数据集中有哪些列是通用的?在我们合并数据源之前,我们需要删除哪些列,或者它们是否足够重要,以至于我们可以在最终解决方案中接受一些缺失的数据?在数据建模中,这些都是需要提前考虑的重要方面,这样我们就可以在编码解决方案的细节中记住它们,并且不会花费时间处理最终数据模型中无法使用的数据。

figure

在这个案例中,数据集已经被识别并为我们提供了,因此我们的“识别”和“获取”步骤已经完成。然而,在现实世界的场景中,考虑我们组织内部可能存在的任何额外客户数据来源是谨慎的。这通常包括各种销售经理在他们的电脑桌面上保存的电子表格!

figure

实际进行数据建模任务时,以下是一些需要考虑的关键步骤:

  1. 我们将首先探索所有三个数据集。我们想要确认列是否包含数据字典中所述的内容。例如,当我们没有客户 ID 时,客户详情是否总是完整的?我们还想看看值是否有意义。我们专注于客户数据,但也可能想看看价格列是否包含任何不切实际的价值。日期和时间值是否都在同一时期内?邮编值是否有任何异常?这是一个迭代过程,所以我们可能不会在开始时就耗尽我们的探索;其中一些问题可能只有在稍后才会出现。我们也不想花费太多时间探索我们不会使用的列。

  2. 一旦我们验证了关于数据的一些关键假设,一个想法是在合并之前对每个数据集进行去重,只保留唯一的客户。这在购买数据中尤其如此,因为每次客户购买多个项目时,他们的详细信息都会重复。我们还会就模式差异做出一些关键决策。如果有些数据只存在于某些来源中,我们该怎么办?

  3. 下一步是将这些单独的数据集合并起来;我们希望得到一个包含所有可能的客户记录的数据集。合并后的数据集可能包含重复项。我们可以通过删除精确重复项来消除一些明显的重复,这些重复项可能出现在客户数据库和 CRM 数据中,并且记录在其他方面完全相同。如果两个客户记录中的信息完全相同,但唯一标识符不同,我们需要小心处理。随意删除重复项可能会导致我们可能称之为“数据来源可追溯性”的丢失,即我们数据最初来源的可追溯性。如果我们有一个 Jane Smith 的客户记录,良好的做法是保留我们在各个数据集中遇到的该客户的所有可能的标识符。也许她在某个数据集中是客户 8834,在另一个数据集中是 931,我们希望以某种方式在我们的最终数据模型中知道这一点。这不仅使追踪她的账户回到其来源更容易,而且增加了对我们最终解决方案的信任。任何使用我们数据模型的人都知道我们关于哪些客户账户构成了 Jane Smith 的“实体”所做出的假设。

  4. 接下来,我们可以考虑在识别精确重复数据之外,对合并后的客户数据进行去重。模糊字符串匹配可能是一个不错的选择;在这种情况下,我们比较两个字符串,如果它们几乎相同,则判断它们为相同。在使用模糊匹配时,会考虑拼写错误,“London”和“Lodnon”被视为相同的字符串。在记录链接和实体解析领域的 研究 可能有助于深入了解。这些是专门用于确定两个略有不同版本的实体实际上是否相同的完整主题。我们需要判断这项额外工作是否有实际效益,并且是否值得我们投入时间。这甚至可能是一个我们留给第二稿的任务,因为我们可能更愿意在承诺进行这一更复杂的步骤之前,向我们的利益相关者展示我们的初步发现。

  5. 最后,我们会清理数据模型,使其具有我们想要的架构,确保列名具有意义。根据我们选择如何处理重复账户,我们可能需要为每个客户实体决定一个主要账户,例如。

figure

展示数据模型的方式是关注我们工作的影响。创建一个小型演示文稿,概述我们所做的工作、我们发现了多少客户以及我们的工作基于哪些假设,这会比提供数据库中的数据模型获得更多的关注。实际上,我们的利益相关者不太可能直接使用我们的数据模型来分析数据;这项工作的主要好处是提高了准确性,并在以后的客户分析中提供了更多机会。

展示我们的发现也为我们与利益相关者一起做出一些分析决策提供了机会。有时,我们可能没有足够的直觉来在两个看似相似的选择之间做出选择。我本人面临的一个问题是当客户在一个数据库中作为公司存在,而在另一个数据库中作为个人存在时。作为分析师的我,不应该是决定“简·史密斯”是否与她的公司“JS Motors”是同一客户的最终决定者;这是一个需要更广泛业务输入的决定,特别是如果业务将随着时间的推移来衡量和跟踪客户数量。您可以使用您数据模型的第一次迭代来向您的利益相关者展示一些这些关键问题,让他们思考。

图

在对您的初步发现获得反馈后,通常可以清楚地知道您解决方案的下一迭代需要什么。在项目背景下,由于我们缺乏来自利益相关者的直接反馈,迭代可能意味着您快速创建一个最小可行数据模型,也许在执行任何有意义的去重之前就停止。除了让您更快地对工作获得反馈外,尽快达到最小可行解决方案还可以让您有信心您正在正确的轨道上。您还将有一个更容易迭代改进的解决方案,而不是在一个更复杂的解决方案上花费大量时间,直到更长过程结束时才有所成果。或者,这可能是明显没有进一步改进解决方案的实质性商业价值的点,因此,而不是进一步迭代,项目被认为是完成的。

3.3.2 需要考虑的问题

在您处理这个项目的过程中,以下是一些需要考虑的关键问题:

  • 客户可以以哪些方式在这些数据集中表示?列出所有场景(CRM 中的客户、已购物的客户、匿名结账等)可能有助于精确地确定您需要编码的内容。

  • 当您考虑重复项时,如何表示关联账户(即主账户与关联账户)?

  • 在去重记录时,您想使用哪些字段进行去重?两个同名且住在同一邮编的人是否一定是同一个人?两个客户的详细信息需要有多少差异,我们才认为他们是不同的人?

3.4 示例解决方案:从交易数据中识别客户

让我们深入探讨一个实际解决方案的细节。我强烈建议在审查示例解决方案之前先尝试自己完成项目。与每个项目一样,数据文件,如第 3.2 节所述,包含在补充材料中。我也建议即使您已经有一个满意的解决方案,也要阅读这一节,不是因为示例解决方案是唯一的解决方案,而是因为我们可以从看到他人如何处理相同的问题中学到很多东西。

3.4.1 制定行动计划

我们首先将探索数据,以查看我们对它的假设是否成立,是否有任何数据缺失,等等。然后,我们将为我们的数据集决定一个共同的架构,并将它们全部裁剪到这个共同的架构中,这意味着我们将有三个较小的客户数据集——一个来自购买,一个来自客户数据库,一个来自 CRM 数据——它们都以相同的方式结构化,可以很容易地组合。在合并数据集之后,我们将消除重复的记录,以得出我们客户群的最好猜测。

3.4.2 探索、提取和合并多个数据源

我们将探索这三个数据集,从中提取共同的客户信息,并将它们合并成一个“主”客户视图。然后,我们将查看消除合并数据集的重复项。让我们从原始购买数据开始。

探索新的数据集

我们首先导入必要的库并读取销售数据:

import pandas as pd
import numpy as np        #1

sales = pd.read_csv("./data/purchases.csv")      #2
print(sales.shape)            #3

1 导入必要的库

2 将我们的购买 CSV 文件作为 pandas DataFrame 读取

3 检查 DataFrame 的大小

这段代码的输出是 (71519, 11),这意味着超过 71,000 行数据,11 列,所以购买表中有超过 71,000 笔交易。我们知道从问题陈述中,访客结账不会构成我们所有交易的全部,所以检查缺失数据至少应该揭示一些缺失的访客信息。下面的代码生成了图 3.4 的输出,显示了每列缺失值的计数:

sales.isnull().sum()     #1

1 一个 pandas 技巧来“加总”缺失值的行

figure

图 3.4 我们购买数据中的缺失值

有 18,448 个缺失的客户 ID,这些应该与访客结账相关,还有 53,071 个缺失的访客值。将它们加起来得到 71,519,这是记录总数,这意味着访客结账和注册用户结账构成了我们的整个数据集。似乎没有一行信息全部缺失或两者都存在,但我们应该验证这一点。首先,让我们创建一个新的列来跟踪访客结账,这发生在客户 ID 未提供时:

sales["is_guest"] = sales["customer_id"].isnull()

现在,我们的 is_guest 列如果客户 ID 缺失,则取布尔值 True。我们可以使用这个列来验证我们对访客结账和客户 ID 互斥的假设。这段代码的第一行检查了交易是访客结账,但我们也有客户 ID 的情况,第二行返回了既没有客户 ID 也没有访客值的情况:

sales[sales["is_guest"] & sales["customer_id"].notnull()]
sales[(sales["is_guest"] == False) & sales["customer_id"].isnull()]

这两条线的输出都在图 3.5 中展示。

figure

图 3.5 检查访客和注册用户结账是否重叠的代码输出

这是 Python 告诉我们没有符合我们标准的记录的方式,这意味着我们可以确信所有行要么是访客退房,要么是注册客户做出的购买。接下来在我们的数据质量议程中,我们需要检查有多少比例的记录是访客退房。这不仅仅是为了信息目的,也是为了我们了解我们将要推断多少客户记录。由于访客退房是我们客户记录的最弱信号,我们添加到客户数据库中的任何访客都被假定为客户。他们是推断出来的,而不是具体的客户账户。

例如,如果有人将 John Smith 作为他们的访客退房名称,这可能是因为史密斯先生代表其他人购买东西,可能是作为礼物。在这种情况下,John Smith 是客户吗?或者可能是有人使用史密斯先生的信用卡,可能是他的孩子之一。在这种情况下,客户是 John Smith 还是孩子?无论如何,我们除了访客名称 John Smith 之外没有更多的信息可以依据,这就是我们需要放入客户数据库的信息。计算访客账户的数量有助于确定我们客户数据中会有多少“假设”案例。使用我们之前创建的is_guest列,我们可以计算其分布:

sales["is_guest"].value_counts(normalize=True)

输出如图 3.6 所示。

figure

图 3.6 客户与注册用户购买的比例

这告诉我们有 25%的行是访客退房,但我们需要记住,每一行代表的是一个购买的项目,而不是客户记录,所以作为访客退房的客户比例不一定为 25%。我们可以计算出这个实际比例:

guest_columns = ["guest_first_name", "guest_surname",
↪ "guest_postcode"]
unique_guests = sales[guest_columns].drop_duplicates()    #1
print(len(unique_guests))
unique_customers = sales["customer_id"].unique()           #2
cust_total = len(unique_customers) + len(unique_guests)
print(len(unique_guests) / (cust_total-1))                 #3

1 获取访客列的唯一组合

2 获取所有唯一的客户 ID

3 从唯一的客户计数中减去 1,因为 NULL 也被计算在内

这会打印出8301的值和另一个略低于0.25的值,这意味着我们有 8,301 种独特的访客列组合,一旦我们提取出独特的客户,结果发现确实有四分之一的人没有注册,而是以访客的身份退房。这个数字不会完全准确,因为可能会有打字错误。我们假设每个姓名和邮编的组合都是唯一的客户,但一个客户在他们的退房过程中犯了一个打字错误,就会导致我们在这里重复计算他们。当然,这是假设他们在退房过程中允许犯打字错误。我们需要了解更多关于实际电子商务系统的信息,以了解这些访客列是否与账单或信用卡信息相关,例如,打字错误可能会导致购买被拒绝。了解数据生成过程至关重要。

让我们总结到目前为止我们已经做了什么。图 3.7 显示了我们在探索购买数据集方面取得的进展。

figure

图 3.7 探索购买数据集的进展

识别数据集之间的共同结构

大约有 25,000 个唯一的客户 ID,代表注册客户,还有大约 8,000 个唯一的推断访客,所以仅从购买数据来看,我们估计客户数量的上限大约为 33,000。我说上限是因为我们稍后必须调查重复账户,如果发现任何重复,这个数字可能会减少。我们还知道,对于访客结账,我们有可用的名字、姓氏和邮政编码。当我们查看其他客户数据库时,我们需要记住这一点。

然而,在我们导出第一个中间数据集之前,我们需要决定我们数据模型的模式。我们知道我们的访客客户有名字和邮政编码,如果我们查看数据字典,我们可以看到客户和 CRM 数据集也包含客户年龄。我们并不真的想因为访客账户缺失而删除该列,所以我们的最终模式将包括它。

一旦记录合并到单个表中,跟踪记录的来源通常是一个好主意。我们可以通过添加一个source列来实现,该列可以具有purchasescustomer databaseCRM等值,但这种结构假设记录只能来自一个地方。我们可能会遇到重复,所以更好的选择是为每个数据源添加一个指示列,即标记记录是否存在于购买数据中的列,另一个指示它是否存在于客户数据库中的列,等等。这些主要用于数据溯源目的,因此信息的来源更加透明。我们还可以决定明确跟踪记录是否来自访客结账,因为这可能在利益相关者想要知道有多少比例的客户在购买时不注册时变得很重要。表 3.5 显示了最终的架构,这是三个数据集需要转换成的模式。

表 3.5 所有数据源需要转换成的数据模型模式
描述
customer_id 客户记录的唯一 ID,或访客为 NULL。
first_name 来自客户或 CRM 表或访客信息。
surname 来自客户或 CRM 表或访客信息。
postcode 来自客户或 CRM 表或访客信息。
age 来自客户或 CRM 表,对访客不可用。
is_guest True 如果数据来自访客结账。
in_purchase_data True 如果此客户记录出现在购买表中。它不是排他的,因为客户也可能出现在客户或 CRM 数据集中。
in_crm_data True 如果客户记录存在于 CRM 数据库中。
in_customer_data True 如果客户记录存在于客户数据库中。

让我们继续将我们的第一个原始数据集,销售数据,转换成这个期望的结构。

将数据集重构为通用结构

从购买数据中导出我们的客户最简单的方法是分别提取访客和非访客,然后将它们合并。这两个子集的结构将不同,因为我们为访客有三个列(名、姓和邮编),而对于注册客户,我们只有他们的 ID。我们可以从客户和 CRM 表中连接数据以找到这些 ID 的相关名字和邮编,或者我们可以在探索和操作客户数据集时这样做。这个选择更多的是个人偏好,我选择将连接推迟到以后,所以现在我们将导出不完整的数据。

要仅导出访客信息,我们可以使用我们的is_guest列进行筛选,并仅导出相关列:

guest_columns = ["guest_first_name", "guest_surname",
↪ "guest_postcode", "is_guest"]
guests = sales.loc[sales["is_guest"], guest_columns]
guests = guests.drop_duplicates()     #1
guests.head()

1 我们删除重复项以确保我们只有唯一的访客信息。

输出结果如图 3.8 所示。

figure

图 3.8 购买表中的访客数据,准备与客户数据合并

对于非访客结账,我们不会有这些列;我们只有客户 ID:

non_guests = (
  pd.DataFrame(            #1
    sales.loc[sales["customer_id"].notnull(), "customer_id"]
      .unique()                    #2
      .astype(int),
    columns=["customer_id"]
  )
)
non_guests.head()

1 客户 ID 是一个单独的列,因此我们需要明确将其转换为 DataFrame。

2 我们从非访客行中提取唯一的客户 ID。

输出结果如图 3.9 所示。

figure

图 3.9 购买中的非访客数据,它只是一个客户 ID 列

图表 3.8 和 3.9 所示的数据结构不同。然而,当我们合并它们时,我们将拥有两个数据集的所有列,以及在一个数据集中某个列不存在时的缺失数据:

sales_customers = pd.concat([non_guests,guests], axis=0, ignore_index=True)

首先,我们将两个数据集连接起来(如果你习惯于 SQL 术语,可以说“合并”)。然后,我们重命名我们的列并移除访客前缀:

new_col_names = ["customer_id", "first_name", "surname",
↪ "postcode", "is_guest"]
sales_customers = sales_customers.set_axis(new_col_names, axis=1)

我们还想要确保没有缺失数据,所以我们在is_guest列中填充缺失值。技术上,我们可以留空以表示某人不是访客,但明确使用True/False值更清晰:

sales_customers["is_guest"] = sales_customers["is_guest"].fillna(False)

现在我们添加了我们为我们的模式所决定的in_purchase_data列:

sales_customers["in_purchase_data"] = True

由于我们正在处理文本数据,另一个重要的步骤是确保没有尾随空格,并且所有名称都使用相同的首字母大小写。这样即使一个名称是小写的而另一个是大写的,客户名称也会被视为相同。我们可以使用pandas内置的.str访问器类,它允许我们操作整个字符串列:

for col in ["first_name", "surname"]:
    sales_customers[col] = sales_customers[col].str.lower().str.strip()

sales_customers["postcode"] = sales_customers["postcode"].str.strip()

现在,从我们的购买中提取的客户数据看起来像图 3.10。

figure

图 3.10 从购买中提取的客户数据预览

前几行显示了非访客结账和注册客户。目前,我们没有他们的名字或邮编,因为我们决定稍后将其连接。预览中最后几行显示了我们的访客,因此缺少客户 ID。

在我们继续之前,让我们总结一下到目前为止我们所做的工作。图 3.11 显示了我们在探索和重塑购买数据集时所采取的步骤。没有形状的文本表示来自前几节中的步骤和决策。

图

图 3.11 我们在购买数据集上所采取的步骤

现在我们已经准备好继续前进,探索客户数据集,并将它们与我们刚刚从原始购买中导出的客户数据合并。

探索第二个数据集

从数据字典中我们知道,两个客户数据集具有相同的架构。我们在两个数据集中寻找的是是否存在任何缺失数据,客户 ID 是否都已填写,以及是否存在任何重复记录。由于客户 ID 是唯一的标识符,我们预计不会出现重复,但不可假设任何事情。我们从 CRM 数据开始:

crm = pd.read_csv("./data/crm_export.csv")
print(crm.shape)
crm.head()

数据集的形状是(7825, 5)`,意味着有 7,825 行和 5 列。图 3.12 显示了 CRM 数据集的预览。

图

图 3.12 原始 CRM 数据的前几行

我们使用以下代码检查缺失数据,其输出如图 3.13 所示:

crm.isnull().sum()

图

图 3.13CRM 表中没有缺失数据

下一步的合理性检查是确保客户 ID 是唯一的。一种方法是按客户 ID 分组,并找到组内有多行记录的实例。如果客户 ID 是唯一的,则不应返回任何记录。让我们验证这一点:

crm.groupby("customer_id").size().loc[lambda x: x > 1]

这里我们使用groupbysize来计算每个客户 ID 的记录数,并使用loc来过滤出有多条记录的实例。Python 输出是Series([], dtype: int64),这表示没有找到记录,因为空方括号在 Python 中表示一个空集合。这意味着客户 ID 确实是唯一的。

然而,这并不意味着客户详细信息在表中是唯一的。如果我们看看我们有多少个独特的姓名、邮编和年龄组合,我们可以看到这一点:

print(len(crm))
print(len(crm.drop(columns="customer_id").drop_duplicates()))

输出分别是 7,825 和 7,419,这意味着虽然 CRM 数据中有 7,825 行,但一旦我们删除客户 ID,只有 7,419 个唯一的列组合,这表明我们大约有 400 个重复的客户详细信息,这些信息分布在多个 ID 上。这可能并不意味着有 400 个重复的客户,因为我们也可能有多个同名的人住在同一个邮编下,但由于我们也考虑了年龄,所以这些很可能是所有冗余的重复。如果存在任何错误重复,考虑到数据集的大小,它们很可能是非常小的百分比,因此没有必要过分关注这一点,现在我们可以这样说,每个姓名、邮编和年龄的唯一组合都是一个唯一的客户。数据建模工作的本质是总会有一个误差范围。

图 3.14 总结了到目前为止我们对 CRM 数据所做的工作。

图

图 3.14 处理 CRM 数据的第一步

将数据集连接起来以增强一个数据集,使用另一个数据集的信息

下一步是将 CRM 数据转换为与购买表中的客户相同的模式,并且我们还需要用 CRM 数据中的详细信息增强购买历史中的注册客户。到目前为止,我们只有这些客户的 ID,但我们需要他们的名字、邮编和年龄。并非所有这些信息都能在 CRM 数据中找到,但我们可以将两者连接起来,尽可能填充更多的行。我们将使用左连接来完成这个操作,因为这将确保无论我们在另一侧找到匹配与否,我们都保留原始数据中的所有行。以下是这个操作的代码,结果如图 3.15 所示:

sales_and_crm_customers = sales_customers.merge(crm,
↪ on="customer_id", how="left", suffixes=("_sales", "_crm"))
print(len(sales_and_crm_customers))
sales_and_crm_customers.isnull().sum()

图

图 3.15 合并销售和 CRM 数据后的缺失值检查

pandas的一个特定特性是,同时出现在两个表中的列默认会得到一个_x_y后缀。我们在这里覆盖了这个特性,使其更具描述性,因此带有_sales后缀的是源数据——购买数据,而_crm后缀是给合并数据的,在这种情况下,是 CRM 数据。

由于销售数据中大约有 33,000 行,而新添加的 CRM 客户列中缺失了 26,000 行,我们可以看到我们在客户 ID 上匹配了大约 7,000 行。这意味着 7,000 次购买的客户记录存储在 CRM 表中。我们现在有一个数据集,其中 7,000 个客户记录位于以_crm结尾的列中,我们应该将这些合并到标记为_sales的列中,这些列包含客户结账时的客户数据。首先,我们定义一个过滤器来选择只有客户 ID 的行,从而排除访客和包含crm后缀列中客户信息的行:

merged_customers_filter = (
  (sales_and_crm_customers["customer_id"].notnull())
    & ((sales_and_crm_customers["first_name_crm"].notnull())
      | (sales_and_crm_customers["surname_crm"].notnull()))
)

然后,可以使用此过滤器来识别这些行在 CRM 数据中已被找到:

sales_and_crm_customers.loc[merged_customers_filter, "in_crm_data"] = True
sales_and_crm_customers.loc[~merged_customers_filter, "in_crm_data"] = False
sales_and_crm_customers["in_crm_data"].value_counts()

输出如图 3.16 所示。

图

图 3.16 合并 CRM 数据后包含客户信息的行数

这个 7,114 的数量与我们之前观察到的相符,即现在大约有 7,000 行客户信息已经更新。现在是时候将crm后缀列中的数据复制到我们_sales后缀列中,并且只保留后者,以回到原始模式:

sales_and_crm_customers.loc[merged_customers_filter, ["first_name_sales",
↪ "surname_sales", "postcode_sales"]] = (
    sales_and_crm_customers.loc[merged_customers_filter, ["first_name_crm",
↪ "surname_crm", "postcode_crm"]]
    .values
)

在这里,我们简单地复制了名字、姓氏和邮编来覆盖sales后缀列中 CRM 客户数据在crm后缀列中的缺失值。现在我们准备移除后者:

sales_and_crm_customers = (
    sales_and_crm_customers
    .drop(columns=["first_name_crm", "surname_crm", "postcode_crm"])
    .rename(columns={
        "first_name_sales": "first_name",
        "surname_sales": "surname",
        "postcode_sales": "postcode"
    })
)
sales_and_crm_customers.head()

输出如图 3.17 所示,这是我们预期的结果。模式现在与之前相同,除了一个新的in_crm_data标志,购买数据中的客户信息已经尽可能用 CRM 数据增强了。

图

图 3.17 合并购买和 CRM 客户数据

在第二行立即注意到一个例子,一个客户使用注册账户购买了商品,所以他们不是客人,但他们的详细信息是通过 CRM 数据集填充的。剩下要做的就是检查并添加存在于我们的 CRM 系统中但未出现在我们的购买记录中的客户详细信息。可能存在一些原因;也许那些客户在电话上购买了商品,而这些销售没有记录在同一地方。无论原因是什么,这是一个我们需要考虑的可能性,以确保全面覆盖。

使用集合交叉引用两个数据集

要找到这些记录,我们使用 Python 技巧从一组客户 ID 中减去另一组,从而得到差集:

crm_ids_to_add = list(set(crm["customer_id"].unique())
↪ - set(sales_and_crm_customers["customer_id"].unique()))
print(len(crm_ids_to_add))

在这里,“集合”指的是一组独特项目的严格数学定义,“差集”则意味着从一个集合中减去另一个集合,从而只留下在 CRM 数据中出现但不在购买记录中的客户 ID。输出结果显示,有 711 位这样的客户需要将他们的详细信息添加到我们不断增长的客户数据集中。我们只需将数据与刚刚选定的 ID 对应的客户数据连接/合并:

sales_and_crm_customers = (
    pd.concat([sales_and_crm_customers,
↪ crm[crm["customer_id"].isin(crm_ids_to_add)]],
              axis=0,
             ignore_index=True)
)

需要清理的此数据的最后一个方面是,对于这些新客户,我们没有源标志的数据,因此我们用默认值填充它们。从 CRM 数据添加的客户将他们的in_crm_data标志设置为Truein_purchase_data设置为False,因为他们不是客人,所以is_guest设置为False。以下代码的输出显示在图 3.18 中:

sales_and_crm_customers["is_guest"]
↪ = sales_and_crm_customers["is_guest"].fillna(False)
sales_and_crm_customers["in_purchase_data"]
↪ = sales_and_crm_customers["in_purchase_data"].fillna(False)
sales_and_crm_customers["in_crm_data"]
↪ = sales_and_crm_customers["in_crm_data"].fillna(True)

sales_and_crm_customers.isnull().sum()

图

图 3.18 合并并清理 CRM 数据后,我们的源标志没有缺失值。

在继续之前,让我们回顾一下我们对 CRM 数据所做的工作。图 3.19 显示了我们所采取的所有步骤。

图

图 3.19 探索 CRM 数据并导出客户信息的步骤

总结一下,我们现在有一个客户数据集,包括所有进行过购买的客户,包括使用注册账户购买的客户,以及来自我们的 CRM 系统的数据,包括仅存在于 CRM 数据中且没有记录购买的客户。现在我们需要用客户数据库中的数据重复这个过程,该数据库的结构与 CRM 数据相似。

探索第三个数据集

现在来看看我们的客户数据库。代码将与用于操作 CRM 数据的代码类似,但这里包含以示完整:

customers = pd.read_csv("./data/customer_database.csv")
print(customers.shape)
customers.head()

输出是(23476, 5)`,这意味着我们有超过 23,000 个客户记录,这比我们的 CRM 数据中的记录多得多。此数据的外观预览显示在图 3.20 中。

图

图 3.20 客户数据的前几行

以下代码检查缺失数据,并产生图 3.21 所示的输出:

customers.isnull().sum()

图

图 3.21 客户数据库中没有缺失数据

我们像处理 CRM 数据那样清理我们的列:

for col in ["first_name", "surname"]:
    customers[col] = customers[col].str.lower().str.strip()

customers["postcode"] = customers["postcode"].str.strip()

现在,我们可以检查客户 ID 是否唯一以及我们有多少独特的客户信息组合:

customers.groupby("customer_id").size().loc[lambda x: x>1]

此代码产生与 CRM 数据相同的输出,即一个空的 Python 集合,这意味着没有相同的客户 ID 出现两次:

print("{} rows".format(len(customers)))
unique_customers = customers.drop(columns="customer_id").drop_duplicates()
print("{} unique combinations of customers".format(len(unique_customers)))

上一段代码的输出告诉我们有 23,476 行代表 19,889 个独特的客户详细信息组合,因此我们可能需要处理大约 3,500 个重复记录。我们将在所有客户数据合并到一个单独的表中后进行此操作。

合并所有我们的数据源

下一步是将客户信息通过连接合并到不断增长的客户数据中。同样,我们给重复的列名赋予有意义的后缀,以显示它们来自哪个表:

all_customers = sales_and_crm_customers.merge(customers,
↪ on="customer_id", how="left", suffixes=("_sales", "_customers"))
all_customers.head()

此合并的输出显示在图 3.22 中。

图

图 3.22 销售和 CRM 客户与客户数据库合并的前几行

与 CRM 数据一样,客户详细信息列有重复项。现在我们将确定哪些行成功合并到客户数据库中,用最终的标志in_customer_data标记它们,并在最终删除冗余列并达到最终模式之前,将这些详细信息复制到以_sales后缀命名的列中。以下代码的输出显示在图 3.23 中:

merged_customers_filter = (
    (all_customers["customer_id"].notnull())
    & ((all_customers["first_name_customers"].notnull())
       | (all_customers["surname_customers"].notnull()))
)
all_customers.loc[merged_customers_filter, "in_customer_data"] = True
all_customers.loc[~merged_customers_filter, "in_customer_data"] = False
all_customers["in_customer_data"].value_counts()

图

图 3.23 新标志的分布,显示客户的详细信息是否出现在客户数据库中

几乎三分之二的销售与存储在客户数据库中的客户详细信息相关。我们现在可以用客户数据库中的详细信息更新原始客户详细信息,即带有_sales后缀的详细信息:

update_filter = (                              #1
    (all_customers["in_customer_data"])
    & (all_customers["first_name_sales"].isnull())
    & (all_customers["surname_sales"].isnull())
)

all_customers.loc[update_filter, ["first_name_sales",
↪ "surname_sales", "postcode_sales", "age_sales"]] = (
    all_customers.loc[update_filter, ["first_name_customers",
↪ "surname_customers", "postcode_customers", "age_customers"]].values
)                                                            #2

all_customers = (                                              #3
    all_customers
    .drop(columns=["first_name_customers", "surname_customers", "age_customers", "postcode_customers"])
    .rename(columns={
        "first_name_sales": "first_name",
        "surname_sales": "surname",
        "age_sales": "age",
        "postcode_sales": "postcode"
    })
)

1 一个过滤器来标记我们将复制的客户详细信息

2 然后我们覆盖那些客户的详细信息。

3 最后,我们将列合并到最终模式中。

我们还需要添加任何出现在客户数据库中但不在购买表中的客户:

customer_ids_to_add = list(set(customers["customer_id"].unique())
↪ - set(all_customers["customer_id"].unique()))
print(len(customer_ids_to_add))

这将返回 1,423 个额外的客户以添加:

all_customers = (
    pd.concat([all_customers,
↪ customers[customers["customer_id"].isin(customer_ids_to_add)]],
              axis=0,
              ignore_index=True)
)

最后,如果它们缺失,我们将更新源标志以反映它们的正确默认值。任何新添加的客户都不是访客,也没有来自购买或 CRM 数据,但存在于客户数据库中:

all_customers["is_guest"] = all_customers["is_guest"].fillna(False)
all_customers["in_purchase_data"]
↪ = all_customers["in_purchase_data"].fillna(False)
all_customers["in_crm_data"]
↪ = all_customers["in_crm_data"].fillna(False)
all_customers["in_customer_data"] 
↪ = all_customers["in_customer_data"].fillna(True)

我们现在可以检查我们合并的客户数据模型的最终模式,如图 3.24 所示:

all_customers.head()

图

图 3.24 从所有三个来源合并的客户数据的预览

从图 3.24 中,我们可以理解客户数据来源的组合。第三行显示我们不得不准备应对的另一个边缘情况——来自注册客户但我们在 CRM 数据或客户数据库中没有其详细信息的购买。我们对这位客户了解甚少,但他们被分配了一个 ID,因此他们的详细信息可能存在于另一个系统中。作为分析师,我们会向业务内部寻求解释。在此之前,我们应该保留这些行,因为它们可能是合法的客户实体,因此应该在数据模型中计数。

让我们总结到目前为止的所有步骤。我们探索了三种不同的客户数据源,将它们转换成相同的模式,并最终将它们合并成一个客户信息表。图 3.25 显示了到目前为止的所有步骤。

figure

图 3.25 处理三个客户数据源并最终合并的步骤

总结到目前为止我们所做的工作,我们已经合并了三个客户数据源,注意覆盖所有可能情况,总计 35,395 条记录。在继续进行去重之前,我们将先了解我们各种客户记录类型的大小。我们可能有四种方式将客户添加到我们的数据模型中:

  • 已识别的客户—进行了购买并且他们的详细信息存在于 CRM 数据或客户数据库中的客户

  • 匿名结账—客户的详细信息来自他们作为访客输入的信息

  • 未知的客户 ID—拥有有效 ID 但客户数据集中没有对应记录的客户

  • 未购买商品的客户—存在于客户数据集中但未出现在购买数据中的客户

这些客户类型是互斥的,它们的数量应该加起来等于整个数据模型。我们可以验证这一点:

identified_customers = (
    all_customers[(all_customers["customer_id"].notnull())
                  & (all_customers["in_purchase_data"])
                  & ((all_customers["in_crm_data"])
                     | (all_customers["in_customer_data"]))]
)

guests = all_customers[all_customers["is_guest"]]

customer_ids_not_found = (
    all_customers[(all_customers["customer_id"].notnull())
                  & (all_customers["first_name"].isnull())
                  & (all_customers["surname"].isnull())]
)
customer_data_only = (
    all_customers[((all_customers["in_crm_data"])
                   | (all_customers["in_customer_data"])
                  )
                  & (all_customers["in_purchase_data"] == False)]
)

print(len(all_customers), len(identified_customers))
print(len(guests), len(customer_ids_not_found), len(customer_data_only))

输出如下:整个数据模型中有 35,395 条记录,其中 23,713 条是已识别客户,8,300 条是访客,1,248 条是未知的客户 ID,2,134 条是未购买商品的客户。第一个数字是其他数字的总和,因此我们可以确信我们没有遗漏任何可能情况,并且它们没有重叠。在这个阶段,我们知道可能存在一些重复,因此我们继续进行客户数据建模的最后部分——实体解析。

3.4.3 应用实体解析以去重记录

可能的一种重复情况是同一客户的详细信息同时出现在 CRM 数据和客户数据库中。在这种情况下,我们的数据模型中可能有完全相同的重复行,这些行很容易删除:

print(len(all_customers))                    #1
all_customers = all_customers.drop_duplicates()
print(len(all_customers))           #2

1 在去重之前检查行数

2 再次检查行数以查看是否有影响

两个语句的输出相同——35,395 条记录——这意味着没有精确的重复项。在这种情况下,这是因为我们在过程中更新了我们的标志。也就是说,存在于多个数据源中的客户只是设置了多个源标志为True,因此没有精确的重复项需要删除。

在这一点上需要考虑的一个因素是,访客结账客户缺少年龄列。这里有一个选择:我们是删除这个列,因为我们的最终数据模型将包含缺失数据,还是包含它但避免在去重中使用它?如果你打算删除数据,最好尽可能晚地做,因此保留该列是有意义的。当我们去重记录时,我们可以决定两个在其他信息上完全相同的客户是否是同一客户,其中一个填写了年龄,而另一个没有。

用唯一标识符填充缺失数据

我们可以做出的另一个决定是,是否给访客账户分配虚假的客户 ID,而不是让他们保持为缺失数据。当我们到达去重步骤时,将账户链接在一起是一个好主意,也就是说,识别与同一基础客户实体相关的客户 ID。对于访客账户,除非它们也有唯一的标识符,否则我们无法做到这一点,因此给他们自己的 ID 是有意义的。一个想法是为访客账户分配一个整数范围。我们可以查看现有的 ID 以查看当前的范围:

all_customers["customer_id"].agg(["min", "max"])

输出告诉我们当前 ID 的范围从数字 1 一直延伸到 9 位数整数,因此一个安全的范围需要远远超出这个范围。一个选项是使用负数来识别每个唯一的访客(即每个访客客户数据点的组合)。对于唯一标识符来说,负 ID 是不寻常的,但为访客分配 ID 范围的替代方法,比如在 12 位数的范围内,感觉同样不自然。另一个选项可能是创建字母数字标识符,比如以“G”开头的数字,表示“访客”,但由于客户 ID 都是整数,这是一个个人选择,不扩展到字母数字:

all_guests = all_customers[all_customers["is_guest"]].copy()
new_ids = np.arange(-1, -(len(all_guests) + 1), -1)                    #1
all_customers.loc[all_customers["is_guest"], "customer_id"] = new_ids

1 我们创建一个从-1 开始递减的自动值范围。

现在,我们的访客客户 ID 范围从-1 到-8300。在这个阶段,我们应该没有出现在多个数据集中的客户重复记录。然而,如果他们在不同的系统中以某种方式收到了两个不同的客户 ID,我们仍然可能有相同的客户的重复记录。拥有 ID 为 123 的 John Smith 可能与拥有 ID 为 456 的 John Smith 是同一个人。

现在,我们处于最后的去重阶段;我们可能不会谈论大量重复的记录。正如我在讨论迭代时提到的,在第一次遍历中,我们甚至可以选择忽略重复项,在这种情况下,我们已经有了一个客户数据模型。然而,这是一个我们希望尽可能准确的任务,并理想地将重复项减少到零。

寻找和链接重复记录

最直接的最初去重方法是说,如果两个客户在名字、姓氏、邮编和年龄方面的值相同,那么他们是同一个客户。然而,如果我们有一个访客账户,它与 CRM 系统中的某个客户相同,而我们又没有在比较中包含年龄,因为访客记录中可能缺少年龄信息,这可能会成为一个问题。仅使用名字、姓氏和邮编可能是一个足够好的起点组合。我们现在可以编写一些代码来找到所有名字、姓氏和邮编完全匹配的客户 ID。

首先,我们创建一个对象,duplicates,它是一个列表,包含所有在我们指定的列中彼此完全相同的客户记录。keep=False参数确保我们保留所有相关记录,而不仅仅是重复的记录。将keep参数设置为其他任何值都会丢弃第一个实例,只保留其他行,即重复项:

columns_to_consider = ["first_name", "surname", "postcode"]

duplicates = all_customers[all_customers.duplicated(
↪ subset=columns_to_consider, keep=False)]

我们现在可以创建一个查找字典,其中每个客户 ID 都链接到所有其他记录,即它的重复记录。图 3.26 展示了这个字典的一个样本:

duplicate_dict = duplicates.groupby(columns_to_consider)['customer_id']
↪ .apply(list).to_dict()

图

图 3.26 重复查找字典的样本

这个字典告诉我们,例如,在邮编为 SO760SX 的地方,Aaliyah Harvey 有两个客户 ID:22648 和 27397。我们可以使用这个字典来创建一个新的列,other_customer_ids,其中我们为具有重复项的账户存储这个列表。图 3.27 展示了结果数据的一个样本:

all_customers['other_customer_ids'] = all_customers.apply(
↪ lambda x: duplicate_dict.get((x['first_name'],
↪ x['surname'], x['postcode'])), axis=1)

图

图 3.27 具有新other_customer_ids列的行样本

图 3.27 显示,例如,邮编为 HR250EJ 的 Harley Palmer 有两个客户记录:ID 31266 和 5411。严格来说,我们的other_customer_ids列不应该自引用,因此我们应该从其中移除客户的自身 ID。我们可以创建一个小的函数来完成这个任务,并将其应用于具有重复项的行。图 3.28 显示了运行以下代码后的数据。从这些数据中,我们可以注意到一个实例,即邮编为 M902XX 的 Max Moore 的访客账户作为重复项与一个已注册的客户 ID 相关联:

def remove_own_record(row):
    ids = list(row["other_customer_ids"])
    ids.remove(row["customer_id"])
    return ids

all_customers.loc[all_customers["other_customer_ids"].notnull(),
↪ "duplicate_customer_ids"] = ( all_customers[all_customers["other_customer_ids"].notnull()]
↪ .apply(remove_own_record, axis=1)
)

图

图 3.28 新的duplicate_customer_ids

作为提醒,我们希望我们的数据库包含每名客户一行,并且每个有重复记录的客户都以某种方式标记这一事实。目前的数据,对于同一客户实体有多个行,每个行都是从不同角度的,如图 3.29 所示。客户 31266 和 5411 可能是同一实体,他们的重复记录是从两个角度记录的。

figure

图 3.29 同一客户实体以两行表示

解决这个问题有两种方法。一种是将其中一个重复记录完全删除,从而将数据减少到每个实体一行。duplicate_customer_ ids列仍然会记录这个客户实体被多个客户 ID 引用的事实,但重复 ID 的客户数据的其余部分将不再存在。理想情况下,每行都是一个去重后的客户记录,但另一个选项是创建一个is_main标记来识别它们。优点是保留了所有数据来源,缺点是你不能再简单地计数行数了;你需要记住每次都要过滤is_main。选择哪种表示方式更适合你的数据模型将再次取决于数据模型将使用的上下文。技术上,如果客户 1480 和 1481 是同一客户,他们是同一客户实体但两个不同的记录,我个人的偏好是尽可能少地删除数据,所以在示例解决方案中,我使用了is_main标记方法。

无论你选择哪种表示方式,你仍然需要决定哪个客户记录是主要的。一种方法是简单地使用你遇到的第一个。这不太可能造成大的差异,但更原则性的方法可能是使用更好的指标,比如交易次数、总消费等等,来决定哪个客户记录应获得“主要”状态。

创建标记的技术技巧是生成一列,为每个重复项分配一个排名和它们被遇到的行号。任何排名为 1 的项简单地成为主账户。这种方法适用于重复项和唯一记录,因为客户详情组合的第一个实例总是会有一个排名为 1:

all_customers["rank"]
↪ = all_customers.groupby(columns_to_consider).cumcount()+1

图 3.30 显示了与图 3.24 相同的重复对,并添加了新的rank列。

figure

图 3.30 带有新rank列的数据

现在,我们每条客户记录一行,但要计算不同的客户实体,我们可以创建我们的is_main标记,使数据模型更明显。一旦我们这样做,我们就不再需要我们的rank列:

all_customers.loc[all_customers["rank"] == 1, "is_main"] = True
all_customers["is_main"] = all_customers["is_main"].fillna(False)
all_customers = all_customers.drop(columns="rank")

最后,我们可以计算记录数:

print(f"Total customers in DB: {len(all_customers)}")
print(f"Of which {len(all_customers[all_customers['is_main']])}
↪ are unique/main records")

输出显示,在 35,395 条记录中,有 27,394 条是唯一的/主要记录。这假设两个具有相同姓名和邮编的客户是同一个客户,并且只有完全相同的重复。为了确保我们的解决方案尽可能准确,考虑到数据,我们可以尝试匹配几乎相同的记录。

使用实体解析工具来提高去重

一旦我们的数据集去重完成,大约在 27,000 个记录左右,我们可能希望在未来的迭代中应用更高级的想法。一个是模糊字符串匹配,用于链接因简单错误而不同的账户。另一个想法是调查是否可以使用购买模式来识别相同的客户。在两个客户在大多数列上匹配但,例如,年龄不同的情况下,你可以使用额外的信息,如他们的购买记录,来决定他们是否指的是同一个客户。对于这个例子,我们将识别几乎相同的账户,看看这是否有影响。当涉及到更复杂的话题,如记录链接时,我们通常可以使用 Python 包而不是自己实现任何算法。在这种情况下,我们将使用 Python 记录链接工具包,该工具包高效地实现了多个记录去重和链接算法。作为一名分析师,一个重要的方面是确定何时使用他人的工作。只要我们知道输出应该是什么,并且能够在出现问题时进行调查,我们就不一定需要在使用外部库之前对底层算法和实现有深入的了解。

让我们将数据输入到这个工具包中,一个名为recordlinkage的模块:

import recordlinkage

我们可以有效地遵循工具包数据去重页面上的基本教程(mng.bz/nRYa)并根据我们的需求进行修改。首先,我们对数据集进行索引,这样我们就不需要尝试比较每一对记录,而是告诉代码在同一邮编下的两个记录应该被测试是否存在重复。这假设邮编没有错误,但这可能并不总是成立,但这样我们的代码运行速度会更快,因为需要比较的记录更少。有时,我们需要在准确性和性能之间进行权衡:

indexer = recordlinkage.Index()     #1
indexer.block('postcode')                #2
candidate_links
↪ = indexer.index(all_customers.set_index("customer_id"))      #3

1 创建一个索引对象

2 将邮编标记为用于索引的列

3 将索引应用于数据

我们将索引设置为customer_id列,因为这样我们的最终匹配数据集将保留这个名称,正如我们将看到的。接下来,我们设置比较规则:我们的记录应该如何相互比较?匹配应该是精确的还是允许模糊匹配?我们还可以选择用于字符串之间模糊比较的算法。在这里,我们使用 Damerau–Levenshtein 方法,这是一种编辑距离的度量,即从一个字符串到另一个字符串所需的单个字符编辑次数。距离越高,两个字符串越不相似。在这里,您可以尝试不同的比较方法并观察结果:

compare = recordlinkage.Compare()              #1
compare.string('first_name', 'first_name', method='damerau_levenshtein',
↪ threshold=0.85, label="first_name")                                #2
compare.string('surname', 'surname', method='damerau_levenshtein',
↪ threshold=0.85, label="surname")
compare.exact('postcode', 'postcode', label="postcode")     #3

1 创建一个比较对象

2 名称应该是模糊比较;任何超过 85%相似度的都是匹配。

3 邮编应该完全匹配。

现在,我们准备使用我们的比较规则进行成对比较。这创建了一个包含所有比较对以及每个比较标准是否满足的 DataFrame。输出样本如图 3.31 所示:

compare_vectors = compare.compute(candidate_links,
↪ all_customers.set_index("customer_id"))

figure

图 3.31 展示了我们的记录链接尝试的输出样本

这个样本显示,例如,客户 ID 7523 和 7466 在邮编上匹配,但在姓名上不匹配。我们可以将数据缩减到所有比较都返回匹配的案例:

matches = compare_vectors[compare_vectors.sum(axis=1) == 3]

输出的结构与图 3.31 相同,但只有完美匹配。接下来,我们应该将这些客户 ID 合并回原始数据集,看看我们是否改进了之前的记录链接尝试:

match_df = pd.DataFrame(            #1
    data=matches.index.tolist(),
    columns=["customer_id_1", "customer_id_2"]
)
matched = all_customers.merge(match_df, left_on="customer_id",
↪ right_on="customer_id_1", how="left", suffixes=("_customers", "_matches")) #2
matched = matched.merge(match_df, left_on="customer_id",
↪ right_on="customer_id_2", how="left", suffixes=("_customers", "_matches")) #3

1 创建一个新的 DataFrame,只包含两个客户 ID 列

2 在第一个客户 ID 上连接客户数据

3 在第二个客户 ID 上连接客户数据

matches数据集已经去重,即如果客户 1 和客户 2 是重复的,我们不会从每个客户的角度各自有两个记录,因此在两个客户 ID 上连接意味着我们确保将主要客户与recordlinkage找到的所有重复项合并。图 3.32 显示了当前合并数据的相关列。有一个实例,即由查找精确匹配创建的duplicate_customer_ids列和由最新的模糊匹配尝试创建的新customer_id列在记录 5411 和 31226 上达成一致。

figure

图 3.32 展示了将链接的记录合并回我们的客户数据模型后添加的新列

我们需要解决的新合并数据的一个问题是,现在具有多个重复项的记录现在是重复的(例如,客户 ID 30730),如图 3.33 所示。

figure

图 3.33 显示有三个重复项意味着有三行数据,我们需要将它们合并为一行

要将这些行合并成一行,就像我们在duplicate_customer_ids列中所做的那样,我们可以编写一个小的函数来收集给定客户 ID 的所有关联客户 ID。我们连接了我们的关联对两次,因此customer_id_1_customerscustomer_id_2_matches列都可以引用我们的客户,而customer_id_2_customerscustomer_id_1_matches列可以引用重复的 ID:

def merge_duplicates(group):
    duplicate_list = []
    if np.isnan(group["customer_id_1_matches"].values[0]) == False:
        duplicate_list.extend(group["customer_id_1_matches"].tolist())
    if np.isnan(group["customer_id_2_customers"].values[0]) == False:
        duplicate_list.extend(group["customer_id_2_customers"].tolist())
    if len(duplicate_list) > 0:
        return sorted(list(set([int(x) for x in duplicate_list])))
    return np.nan

linkages = (
    matched
    .groupby("customer_id")
    .apply(merge_duplicates)
    .reset_index(name="linked_duplicates")
)

我们的功能将多个行中的必要值收集到一个列表中,然后我们依次将此函数应用于每个客户 ID,将数据集减少到每个客户 ID 一行。输出如图 3.34 所示。

figure

图 3.34 我们客户 ID 与其重复项链接的预览

我们可以使用customer_id列将此数据合并回我们的数据模型,这样我们就有了两组重复数据可以并排比较——只使用精确匹配的那一组,以及最新的一组,它还使用了模糊字符串匹配:

all_customers = all_customers.merge(linkages, on="customer_id", how="left")

我们的数据现在看起来就像图 3.35 中的那样。

figure

图 3.35 包含两种不同去重方法结果的客户数据

现在,我们可以比较两个列duplicate_customer_idslinked_duplicates不一致的实例。图 3.36 显示了以下代码的输出中的一些行:

(
    all_customers[(all_customers["duplicate_customer_ids"].notnull())
               & (all_customers["linked_duplicates"].notnull())
               & (all_customers["duplicate_customer_ids"]
↪ != all_customers["linked_duplicates"])]
)

figure

图 3.36 两种去重方法不一致的实例

让我们检查这些情况中的一个,如图 3.37 所示。我们观察到,对于 Scarlett Jackson,recordlinkage找到了两个额外的重复记录,其中一个指的是 Sgarlett Jagkson,另一个指的是 Scariett Jackson。所有这些很可能是同一个客户,所以使用模糊方法来查找所有可能的重复记录是有意义的。

figure

图 3.37 两种去重方法找到不同结果的一个实例

最终,只有 13 种情况下两种方法不一致,所以使用recordlinkage似乎只给我们带来了微小的改进。然而,我们现在有了未来可以更智能地去除客户数据的代码,并且我们结果的确切性得到了提高。

在我们继续到结论和建议之前,让我们回顾一下图 3.38 中显示的整个分析过程。与所有分析一样,你的具体路径可能已经偏离了我选择的道路。

figure

图 3.38 本项目的整个分析过程图

现在我们已经完成了工作并记录了我们的分析,让我们继续到结论和建议。

3.4.4 结论和建议

我们最初的难题是请求帮助计数客户。我们的最终数据模型有 35,395 行,对应 27,394 个独特的客户,这与我们最初的“最多 33,000 名客户”的估计相符。然而,这个数字更加精确,因此更有用,因为它是我们迄今为止所做所有分析的结果。如果你决定使用recordlinkage库的结果来创建去重数据模型,这个数字可能会有所不同。

我们如何评估最终解决方案的质量?这很难量化,因为没有基准事实可以对照检查,但了解数据模型中存在的不同完整程度是个不错的主意。

例如,我们在购买数据中有大约 26,000 个客户 ID,这些 ID 在任一客户数据集中都没有对应的记录。这意味着我们有 26,000 名客户已经注册在线购买,但我们没有他们的详细信息,因为他们没有使用访客结账,所以我们最终数据模型中只有他们的客户 ID。我们可以选择将他们作为不完整的记录删除,但这会歪曲我们对客户群规模的测量。更好地理解我们的数据模型在完整性上有所不同,适合某些任务——如计数客户——但不完全适合其他任务,如客户细分。

这是我们基于数据模型进行分析时应该与利益相关者分享的结论。这也是我们需要学会接受和传达的不确定性和模糊性。

无论你使用什么方法,实体解析很难自动化达到 100%。总会有一些边缘情况使得数据模型低于 100%的准确性。这个任务的价值在于,一旦你有了“最佳猜测”的客户数据模型,你可以确信,所有后续的分析,虽然不是完全准确的,但将是基于你所拥有的数据的最佳分析。此外,每次分析不必从定义我们所说的客户开始,因为这项工作已经在数据模型中完成了。

活动:使用这些数据的进一步项目想法

电子商务数据中充满了等待被发现的模式和趋势。想想看,可以用这些数据回答的其他研究问题。以下是一些启发思考的想法:

  • 有没有一种方法可以根据他们的购买历史来去重具有相同名字的客户?如果有两个在相同邮编下但客户 ID 和购买档案不同的 John Smith,这会使得他们不是同一个人的可能性降低吗?

  • 数据中是否包含关于家庭的信息?也许是在同一邮编下同姓的人?

  • 购买行为在注册客户和作为访客结账的人之间是否有差异?

3.5 对数据建模的总结思考

在本章中,您尝试了一个数据建模任务,可能没有正式的数据建模培训。如果您对这个主题感兴趣,一个开始的地方是拉尔夫·金伯尔(Ralph Kimball)和玛吉·罗斯(Margy Ross)的经典著作《数据仓库工具箱》(The Data Warehouse Toolkit)(Wiley, 2013)。有一些关键的数据建模概念,如“星型模式”(star schemas)和“事实表”(fact tables),需要探索以获得对数据建模最佳实践的更深入理解。一种不那么技术化、更以业务为导向的方法是研究业务事件分析与建模(BEAM)技术。其背后的理念是,核心业务实体是关注业务生命周期中发生的事件。您的数据模型将采取“客户购买产品”的形式,其中一条记录是客户购买产品的单个实例,包括有关客户、产品和购买事件的所有详细信息。从事件的角度思考迫使您思考业务实际上是如何运作的,以及最终生成您原始数据的过程。一个相关的文本是劳伦斯·科尔(Lawrence Corr)和吉姆·斯塔吉托(Jim Stagnitto)的《敏捷数据仓库设计》(Agile Data Warehouse Design)(DecisionOne Press, 2011)。

最重要的是,您要考虑数据的目的,以及因此必要的细节,并养成创建数据模型的习惯,这些模型将原始数据的复杂性抽象为更相关的业务形式。这项技能将在您的大多数分析项目中出现。

3.5.1 任何项目的数据建模技能

在本章中,我们专注于创建新的数据集,我们称之为数据模型。为数据建模学习到的具体技能,可用于任何问题,包括

  • 探索多个数据集以识别共同的结构

  • 重新塑造数据集以符合这种共同结构

  • 确定数据模型应存储的形式(例如,宽或长)

  • 将多个数据集连接起来,以增强其中一个数据集,使其包含来自另一个数据集的信息

  • 交叉引用两个数据集(即查找只出现在其中一个数据集中的行)

  • 将较小的数据模型组合成主数据模型

  • 使用简单方法去重记录

  • 使用高级方法,如实体解析工具,去重记录

摘要

  • 思考数据集的目的有助于确定您数据模型正确的结构。

  • 数据建模是分析师的一项关键技能,应应用于从原始数据集中创建干净、定义明确、去重、重构和可用的数据。

  • 即使是像计数这样的基本分析任务,在数据正确建模的情况下也更容易完成。

  • 正确的数据建模提供了通过调整粒度级别或提供宽或长视角来轻松重用相同数据以回答额外分析问题的能力。*

第四章:4 指标

本章涵盖

  • 定义指标以使你的项目取得成功

  • 识别错误的指标,这些指标衡量了错误的事情

  • 评估所选指标的影响

在你的职业生涯中,你可能会被要求创建和维护一个跟踪业务关键绩效指标(KPIs)的仪表板。这是因为业务中发生的事情很多,简单的、总结性的指标是最常见的衡量和分析发生情况的方式。而不是从每个业务单元或员工那里获取详细的口头总结,高管们会查看诸如周转率、利润或利润率等关键数字的趋势。

真实业务案例:定义关键业务指标

在我工作多年的二手车拍卖领域,一个重要的指标是转化率。该业务有多个分析和仪表板专门针对转化率相关的问题。

转化率被用来衡量我们能够为我们客户、即卖家,出售的拍卖汽车的比例。这似乎很简单,但结果却是业务中的每个人都对转化的定义没有达成一致,尤其是在汇总多个拍卖活动时。还有一个与之相关但不同的指标,称为“首次转化率”,这对利益相关者来说更难达成一致。

最初只是一个简单的指标,最终变成了一个大项目,涉及业务多个领域的协作来定义这些基本术语。指标定义至关重要,它们构成了本章项目的基石。

我们选择的指标定义了我们所做的每一件事,从董事会到个人分析师,因此我们需要确保它们有很好的定义。

4.1 明确定义指标的重要性

有时候,你会被要求衡量和跟踪一个定义不足或不适合问题的指标。任何分析的第一步,以及我们的以结果为导向的方法,是理解问题——在这种情况下,我们被要求衡量的指标。像“最佳”这样的词(例如,“什么月份是运行广告活动的最佳月份?”)表明某些事情还没有得到适当的定义。

一些常见的使用指标出错的方式包括

  • 过度依赖指标会有意想不到的后果。一个例子是社交媒体平台试图最大化用户的注意力作为他们的关键指标。这激励了点击诱饵和引发强烈情感的内容。

  • 该指标没有包含业务中每个重要的元素。学校被激励最大化学生出勤率而不是教育质量就是一个例子。

  • 该指标没有衡量预期的结果。客户满意度的常见衡量标准是净推荐者分数或 NPS。这个分数的计算方式意味着只有给出 10 分中的 9 分或 10 分的用户才被认为是“推荐者”,而 8 分中的 10 分也被丢弃。这意味着可能得到一个很高的平均分数,但 NPS 却很低,这可能会误导。

在这些情况中,解决方案是选择一个更好地捕捉问题所有方面的指标,选择额外的指标来补充所选的单个指标,或者理想情况下,两者都选择。我们始终应该对声称完美总结复杂问题的单个指标持怀疑态度,并从不同的角度考虑解决方案。

单个指标可以让我们一目了然地了解整个企业的运营情况,但过度简化往往会有后果。一个相关的格言,Goodhart 定律,指出:“当一个衡量标准成为目标时,它就不再是一个好的衡量标准。”一旦你开始针对单个指标进行优化,它就不再是一个好的测量指标。这并不意味着使用简化的指标是错误的,但只是我们应该了解其后果。

本章的项目探讨了这一想法;它完全是关于根据模糊的利益相关者请求定义指标,所以让我们直接进入正题。

4.2 项目 3:为更好的决策制定定义精确的指标

让我们看看这个项目,我们将被要求定义和计算指标以找到表现最佳的产品。数据可在davidasboth.com/book-code上找到,你可以尝试该项目,以及以 Jupyter 笔记本形式的示例解决方案。

首先,让我们看一下问题陈述并检查业务背景。

4.2.1 问题陈述

在这种情况下,你是一名为电子商务初创公司 Online Odyssey Outlet 工作的初级分析师。如果你已经尝试了第三章的项目,实际上它就是同类型的初创公司。数据将与第三章的数据结构相似,如果不是完全相同。

注意:再次感谢 REES46 提供原始交易数据(mng.bz/6eZo),该数据仅为此项目进行了调整,通过过滤掉“查看”事件并仅保留注册用户(那些有有效user_id的用户)来缩小到可管理的规模。

日期是 2020 年 1 月,该初创公司刚刚经历了第一个冬季大促销。高级利益相关者希望了解哪些产品在圣诞节期间表现最好,以便他们可以简化未来促销期间宣传的产品。你将通过分析最多两个月的事件数据来完成这项工作,这些数据以两个具有相同结构的数据源的形式呈现。事件不仅指销售,还包括更普遍的客户行为,如在网上查看产品或将产品放入他们的虚拟购物车。

挑战在于,当被追问时,利益相关者并不确切知道他们所说的“最佳”是什么意思。在最初的头脑风暴会议中,他们强调了在产品排名时关心的某些方面:

  • 销售量

  • 单个产品的总收入

  • 人气,通过购买产品的独特客户数量来衡量

  • 转化率,即产品被放入虚拟购物车后购买的比例

  • 11 月到 12 月表现提升的产品

你的利益相关者没有为这些因素分配权重,因此它们相对的重要性不明确,但你应该在呈现你的发现时考虑一个或多个这些指标。他们也对提出有助于提高未来销售表现的额外指标的建议持开放态度。

4.2.2 数据字典

表 4.1 显示了事件数据的数据字典,图 4.1 显示了部分样本数据。11 月和 12 月的事件分别在不同的表中,但结构相同,因此可以很容易地合并。

表 4.1 事件数据的数据字典
描述
event_time 交易日期和时间
event_type 用户事件的类型,要么是cart(客户将产品放入购物车)要么是purchase(客户购买了商品)
product_id 产品的唯一标识符
category_id 产品类别的唯一标识符
category_code 描述产品主要和子类别的代码
brand 产品品牌(如果适用)
price 产品的价格(添加到购物车时的列表价格,或事件为购买时的销售价格)以美元计
user_id 已注册客户的唯一标识符
user_session 用户浏览会话的唯一标识符

图

图 4.1 事件数据的快照

4.2.3 预期结果

分析的输出应该是针对你选择的维度上表现最佳产品的推荐。推荐可以是单个产品 ID,也可以是更广泛的相关于品牌或产品类别的推荐,只要你在分析中合理地证明了最佳产品的选择。推荐可以以任何格式呈现给利益相关者。这可能是一整套演示文稿,一些精选的可视化,或者甚至只是由你的分析工具生成的数据表。

活动:演示

考虑你将如何呈现你的发现。哪种格式最合适?你的利益相关者最感兴趣的是哪些细节?关键的是,你想要省略哪些不必要的细节?知道如何为合适的受众总结你的工作是重要的分析技能之一。

4.2.4 必需工具

对于本章的示例解决方案,我将使用 Python 库pandas来读取和处理数据集,使用numpy库进行额外的数值计算,以及使用matplotlib来创建可视化。与每个项目一样,你使用的具体技术并不重要,只要它满足每个项目的标准。对于这个项目,你需要一个可以

  • 从 CSV 文件中按百万行顺序加载数据集

  • 创建新列并操作现有列

  • 合并(联合)具有相同结构的两个数据集

  • 执行基本的数据操作任务,如排序、分组和重塑数据

  • 创建数据可视化(可选)

4.3 将结果驱动方法应用于不同的指标定义

让我们再次使用以结果为导向的方法来分解问题。在像这样一个开放性问题中,了解我们的利益相关者希望从分析中获得什么尤为重要。我们需要了解一个最小可行答案,这个答案可以被接受并可能成为未来工作迭代的基石。

figure

这一步在开始工作之前总是至关重要的,但在像这样一个开放性问题中尤其如此。关注利益相关者建议的不同指标意味着什么?

  • 仅关注数量可能会过分夸大人们大量购买的产品相对于其他产品的价值。

  • 仅关注总收入可能会过分强调昂贵的产品。

  • 优先考虑流行度可能仅仅揭示了每个人都购买的产品,例如智能手机、衣服或厨房电器。然而,它可能有助于区分其类别中的“更好”产品(即,我们可以假设几乎每个人都将购买一部智能手机,但不是同一部)。

  • 仅关注转化率可能会突出那些人们不太可能改变主意的产品,但与“最佳表现”是否有直接关系尚不清楚。

  • 特定比较 11 月和 12 月之间的表现将直接针对利益相关者的问题,因为他们询问了圣诞节期间的最佳表现者。

figure

这一步迫使我们甚至在开始之前就考虑我们工作的输出。在这种情况下,输出尚未明确定义。然而,我们知道我们的最小可行答案应该包含

  • 在产品层面(例如,按产品排序的 12 月总收入表)对我们选择的指标进行测量

  • 一个排名,以便我们可以突出表现最好的

  • 选择哪些指标的理由

  • 以表格或视觉形式总结我们的发现

  • 如果需要,提供进一步迭代的建议

这些最终目标帮助我们确定在分析过程中应关注什么,因此我们不太可能无谓地陷入死胡同。

figure

与第三章一样,我们的数据已经提供给我们,没有必要明确标识或获取。然而,我们可能想要执行的一个任务是检查所有可能的指标是否都可以用现有数据进行测量。这可以通过查看数据字典并在分析过程中验证我们的假设来完成。一个需要做出的选择是是否只关注 12 月的事件,还是包括 11 月的数据,以便我们可以在圣诞节前后比较产品性能。在未来的迭代中,我们也可能要求获取上一年的数据来进行年度比较,这是现有数据无法实现的。

figure

在分析过程中,需要考虑的一些步骤包括

  • 如果我们决定调查这些比较指标,则合并 11 月和 12 月的数据。

  • 探索事件数据。我们特别感兴趣的问题包括

    • 什么使产品独特?其 ID 是否足够,ID 是否真的是一个唯一的标识符?有些产品目录可能会为不同的颜色变体多次列出相同的产品 ID,在这种情况下,ID 将不足以作为唯一的标识符。

    • 我们的数据中是否存在任何空白(例如,我们没有事件日期的日期)?识别空白将有助于我们了解可用数据的局限性。

    • 通常表现最好的类别/子类别/品牌是什么?这将有助于确定我们后来的结果是否在上下文中合理。

    • 产品价格分布如何?是否有需要进一步调查的异常值?如果我们选择探索产品收入,这一点尤为重要。

    • 产品流行度和转换率的基线是什么?再次强调,了解典型产品的转换率或一般有多少人购买某样东西将有助于将结果置于适当的背景中。

  • 将数据聚合到产品级别,因为原始数据是在交易级别。

  • 计算每个产品的选择指标

  • 如果我们选择测量多个指标,则比较多个指标的产品

  • 以表格或视觉形式总结我们的发现

  • 如果利益相关者希望根据我们的发现深入了解问题,则建议进一步的工作

figure

在展示我们的发现时,我们希望专注于总结我们的方法、我们选择的指标以及产生的最佳产品。需要关注的重要方面是“那么呢?”我们的发现应以建议采取具体行动的方式呈现。告诉利益相关者袜子全年销量良好,圣诞节期间销量激增,对他们来说可能不是新闻。如果我们选择创建幻灯片,我们应该力争三到四张幻灯片,自然流畅,简洁的信息,以及适当的可视化。在我们的代码中,这意味着我们应该创建最有效地传达我们发现的可视化。

figure

我们通常希望在有一个最小可行答案时就向利益相关者展示我们的发现。这可能意味着在分析完成之前进行迭代。然而,在这个例子中,要调查的可能指标已经明确定义,我们应该为每个指标在我们的产品和可能还在类别和品牌上计算出一个值。这样,在讨论未来的迭代时,我们可以关注那些已经提出的指标之外的指标。

4.3.1 需要考虑的问题

在这个分析过程中,你应该始终思考以下内容:

  • 每个选择的指标的定义是什么?像“量”或“收入”这样的词听起来很直观,但在计算之前需要严格定义。

  • 选择特定指标作为“最佳”定义的影响是什么?“最佳”应该衡量什么,我们的定义将如何对利益相关者有用?

4.4 一个示例解决方案:寻找表现最佳的产品

让我们通过一个示例解决方案来解决这个问题。就像每个项目一样,我鼓励你在查看解决方案之前先尝试自己完成项目,并记住这只是一多种可能的解决问题的方法之一。探索步骤的顺序并不总是重要的。你可能会选择先调查数据的不同方面,而不是我选择的那些,这也是可以预料的。

至于我们的行动计划,我们首先将 11 月和 12 月的事件合并,并探索合并后的数据,同时解决任何数据问题。接下来,我们将数据聚合以生成产品级别的数据集。然后我们可以定义和计算多个指标,以查看产品排名在不同指标中的差异。最后,我们将总结我们的发现,看看我们可以向利益相关者提出哪些初步建议。

4.4.1 合并和探索产品数据

由于我们的两个数据集,11 月和 12 月的事件,在结构上是相同的,我们将在它们合并后只探索一次数据。让我们从这里开始:

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

november = pd.read_csv("./data/november.csv.gz")
december = pd.read_csv("./data/december.csv.gz")

events = pd.concat([november, december], axis=0, ignore_index=True)
print(events.shape)

在这里,我们使用pandas库来连接两个数据集。如果你熟悉 SQL,这相当于一个并操作。代码的输出是(7033125, 9),这意味着我们有超过 700 万行和九列的数据。

现在是时候对数据进行一些基本合理性检查了。我们有没有任何缺失值?图 4.2 显示了查找缺失数据的输出:

events.isnull().sum()

图

图 4.2 我们事件数据中每列缺失值的行数

我们有一些用户会话缺失,这意味着我们不知道这 27 个事件属于哪个独特的浏览会话。这不应该影响我们的分析,因为我们感兴趣的是产品,所以我们可以保留这些行。对于品牌列来说,缺失值在这个阶段并不一定是个问题,因为我们还不知道我们将如何使用这些信息。

让我们开始构建我们的图表来记录我们的步骤。到目前为止,我们已经完成了图 4.3 中显示的步骤。

figure

图 4.3 我们分析的第一步,可视化展示

是时候继续前进,验证我们数据中的不同假设了,包括关于日期范围和产品分类的假设。

验证数据中的假设

我们的下一个假设是我们确实有 11 月和 12 月的数据,我们应该独立验证。图 4.4 显示了检查我们数据中日期范围的输出:

events["event_time"] = pd.to_datetime(events["event_time"],
↪ format="%Y-%m-%d %H:%M:%S %Z")
events["event_time"].agg(["min", "max"])

figure

图 4.4 我们数据中的日期范围

我们的数据在预期的范围内(11 月 1 日至 12 月 31 日),但我们仍然不知道我们是否每天都有事件数据。以下代码计算了每个月的独特天数,其输出结果如图 4.5 所示:

(
    events
    .assign(month=events["event_time"].dt.month,
            day=events["event_time"].dt.day)      #1
    .groupby("month")
    ["day"]
    .nunique()     #2
)

1 使用 assign 函数创建临时列用于日和月

2 nunique 函数计算每个月的独特天数。

figure

图 4.5 每月遇到的天数的唯一数量

输出结果符合预期,因此我们数据中的每一天至少有一个事件。最后,我们还想了解事件数量是否在这些天中保持一致。以下代码计算并可视化了这个结果,输出图表如图 4.6 所示:

fig, axis = plt.subplots(figsize=(10, 6))

(
    events
    .assign(month=events["event_time"].dt.month,
            day=events["event_time"].dt.day)
    .groupby(["month", "day"])
    .size()
    .plot
    .bar(ax=axis)
)

labels = (
    pd.date_range(                   #1
        events["event_time"].dt.date.min(),
        events["event_time"].dt.date.max(),
        freq="D")
    .strftime("%b %d")     #2
)

axis.set(title="Number of events per calendar day",
         xlabel="Number of rows",
         ylabel="Calendar day",
         xticklabels=labels)

plt.show()

1 根据数据中的日期创建自定义轴标签

2 标签格式化为“月日”(例如,“Nov 1”)

figure

图 4.6 每个日历日的活动数量,可视化展示

我们的数据清楚地显示了每天都有广泛的量级范围。我们关注的是图表中是否存在任何方向上的异常值。有七天低活动日,它们对应于 11 月的第一周。这可能意味着客户还没有像现在这样频繁地使用该平台,知道网站启动的确切时间将有助于将这些情况置于上下文中。在 11 月中旬也有三天特别高的活动。同样,这可能是因为用户参与度的自然增长或社交媒体帖子走红。这可能是由于当时进行的广告活动或促销活动造成的,或者可能是因为有销售活动。没有更多信息,我们只能猜测,但这是我们看到这些结果时需要做的猜测。

我们接下来要关注的是事件类型。我们数据中不同事件的比例是多少?图 4.7 显示了以下代码的输出:

events["event_type"].value_counts(dropna=False)

figure

图 4.7 不同事件类型的比例

在典型的电子商务环境中,用户从探索开始,然后把商品放入购物车,最后购买它们,形成了一个事件漏斗。正如预期的那样,在这种情况下,我们观察到这个漏斗变窄了,这意味着我们看到的购买事件比购物车事件少得多。

现在是时候将我们的注意力转向产品目录,并调查电子商务平台提供的产品类型。首先,我们产品的典型价格范围是多少?图 4.8 显示了我们对价格的研究结果:

fig, axis = plt.subplots()

(
    events["price"]
    .hist(bins=25, ax=axis)
)

axis.ticklabel_format(useOffset=False, style='plain') #1

axis.set(title="Distribution of product price",
         xlabel="Product price ($)",
         ylabel="Frequency")

plt.show()

1 禁用科学记数法

figure

图 4.8 产品价格分布

这种输出在价格数据中很常见,即小值的聚类和向右的长偏斜。这意味着我们的大部分产品都标价或销售在 500 美元以下,有些异常值高达 2500 美元。

那么,品牌情况如何?图 4.9 展示了使用以下代码获取的前 10 大品牌。故意不包括缺失的品牌:

events["brand"].value_counts().head(10)

figure

图 4.9 按事件数量排名的前 10 大品牌

一眼看上去,这似乎表明我们的大部分用户事件都集中在技术类别上。我们独特的商品目录的实际构成可能不同,如果我们只看购买事件,前 10 大品牌也可能不同。现在我们已经看了品牌,让我们也看看产品类别。

调查产品类别的一致性

我们的产品是否被正确分类?这是我们每次拥有带有 ID 和相关分配类别的数据集时都应该提出的问题。一种检查方法是问,我们是否有任何产品 ID 被分配到多个类别?以下代码对此进行了调查,输出显示在图 4.10 中:

(
    events
    .groupby("product_id")
    ["category_code"]
    .nunique()                     #1
    .loc[lambda x: x > 1]          #2
    .sort_values(ascending=False)
)

1 首先,计算每个产品 ID 遇到的唯一类别代码的数量

2 查找计数超过 1 的实例

figure

图 4.10 调查分配给多个类别的产品的结果

如我们所见,近 13000 个产品被分配到多个类别。这表明我们的基础产品目录中可能存在某个错误。让我们查看顶级类别代码以进一步调查。图 4.11 显示了查看前 10 个最常见类别代码的输出:

events["category_code"].value_counts().head(10)

figure

图 4.11 按行数排名的前 10 个类别代码

这并不一定意味着我们的大部分产品都在“轻型建筑工具”类别中,因为这是事件数量,而不是独特产品的数量,但它确实表明了一个与我们在图 4.10 中发现的类似问题。让我们通过查看这个“轻型建筑工具”类别中的前 10 大品牌来了解品牌和类别代码如何交叉。以下代码生成了图 4.12 中的输出:

(
    events
    .loc[events["category_code"] == "construction.tools.light", "brand"]
    .value_counts()
    .head(10)
)

figure

图 4.12 construction.tools.light类别中的前 10 大品牌

除非技术制造商秘密扩展到建筑工具领域,否则我们在产品类别上存在问题。这些品牌通常以智能手机而闻名,我们可以看看一些例子来验证这一点。以下代码找出所有针对多个代码的产品 ID,以便我们知道哪些需要关注。图 4.13 显示了这些产品 ID 子集的样本:

dupe_product_ids = (
    events
    .groupby("product_id")
    ["category_code"]
    .nunique()
    .loc[lambda x: x > 1]
    .index
    .values
)

dupe_product_ids[:10]

figure

图 4.13 具有多个分配类别的产品 ID 子集

从这个数据子集中,我们可以找到一类是“轻型建筑工具”的实例,并查看该产品被分配到哪些其他类别。以下代码和图 4.14 展示了示例:

(
    events.loc[events["product_id"] == 1001588,
               "category_code"]
    .value_counts()
)

figure

图 4.14 特定错误产品的类别分解

在这个例子中,我们有一部也被错误分类为建筑工具的手机。我们现在必须做出一个重大决定:我们如何处理这些产品?我们有几个选择:

  • 忽略这个问题 — 缺点是,由于我们知道它经常被错误分配,我们将无法使用分类代码来调查表现最好的产品。

  • 丢弃错误分类的产品 — 问题在于这可能是我们数据中相当大的一部分。

  • 修复产品分类 — 这看起来是最好的方法,但我们可能没有足够的信息来为每个产品找到正确的类别。

在现实中,我们希望了解更多关于这些产品类别是如何产生的信息,理想情况下,我们应该从源头解决问题。然而,在这种情况下,我们只有这些数据可以工作,所以让我们尝试使用我们拥有的数据来修复问题。

修复数据类别的不一致性

我们的行动计划是找到具有重复类别的产品,对于其中一个是construction.tools.light的情况,我们使用另一个类别。对于不涉及construction.tools.light类别的重复情况,我们需要相应地调查或使用多数类别。例如,如果一个产品 ID 主要被分类为冰箱,但有时被分类为智能手机,我们将覆盖数据,使产品 ID 始终位于冰箱类别中。存在错误分类某些产品的风险,但我们也将会修复建筑工具类别的错误。

以下代码定义了一个修复单个产品 ID 分类代码的方法,并将其应用于所有重复的产品 ID 数据。输出是受影响的产品 ID 的每个新分类代码,如图 4.15 所示:

def get_correct_category_code(product_id_rows):
    categories = product_id_rows["category_code"].value_counts() #1

    if "construction.tools.light" in categories.index: #2
        return categories.index.drop("construction.tools.light").values[0]
    else:
        return categories.index[0] #3

corrected_categories = (
    events[events["product_id"].isin(dupe_product_ids)]
    .groupby("product_id")
    .apply(get_correct_category_code)
    .reset_index(name="corrected_category")
)

corrected_categories.head()

1 product_id_rows 包含一个产品 ID 的所有行,因此我们可以找到与该 ID 相关的两个类别。

2 如果一个是construction.tools.light,则返回另一个。

3 否则,返回多数类别。

figure

图 4.15 我们将使用以纠正产品 ID 类别的数据预览

如图 4.15 所示,我们有很多产品应该是智能手机,但并不总是被这样分类。现在,我们将这个修正后的分类数据与我们的原始事件数据集合并,并覆盖原始和修正分类不匹配的分类。为了验证我们的更改,我们将再次查看顶级分类代码,并将我们的结果与图 4.11 进行比较。以下代码产生了一个类似的结果,如图 4.16 所示:

events = events.merge(corrected_categories, on="product_id", how="left")
events.loc[events["corrected_category"].notnull(), "category_code"] = \
↪ events.loc[events["corrected_category"].notnull(), "corrected_category"]

events["category_code"].value_counts()

figure

图 4.16 应用我们的修正后分类代码的细分

现在,我们的数据主要是由智能手机组成,这与我们关于最受欢迎的品牌的研究结果更为一致。需要注意的是,我们可能高估了智能手机的普及率,因为我们对如何处理数据做出了特定的假设,这些假设可能并不正确。然而,在现有数据中,我们没有足够的信息来进一步调查这一点。

检查后,这个分类代码列还包含一个“主要”类别,对应于字符串的第一个部分,直到第一个点字符,这可能对我们的分析有用,所以让我们将其隔离出来。以下代码的输出显示了这一主要类别的细分,如图 4.17 所示:

events["category"] = events["category_code"].str.split(".").str[0]

events = events.rename(columns={"category_code": "subcategory"})

events["category"].value_counts()

figure

图 4.17 产品“主要”类别的细分

这个表格进一步使我们相信,我们的大部分产品都是某种类型的电子产品。在我们继续在产品级别总结数据之前,我们想要确保特定的产品 ID 只指代一个产品。我们将很快到达这一步。在继续之前,让我们总结一下到目前为止的工作。图 4.18 显示了我们所采取的步骤以及我们的决策可能发生分歧的地方。

figure

图 4.18 我们到目前为止的分析步骤

产品分类的另一种方式是使用它们的品牌。我们也应该调查是否有任何产品 ID 对应多个品牌。考虑到我们对brand列有缺失数据,这似乎是一个相关的步骤。

调查品牌标签的一致性

在这里,为了调查每个产品是否只附有一个品牌,我们遵循了一个与我们在产品分类时非常相似的过程。以下代码的输出显示在图 4.19 中:

duplicated_brands = (
    events
    .assign(brand = events["brand"].fillna("No brand"))   #1
    .groupby("product_id")
    ["brand"]
    .nunique()
    .loc[lambda x: x > 1]
    .index
)

print(len(duplicated_brands))

duplicated_brands[:10]

1 使用占位符暂时填充缺失的品牌数据,因为 nunique 函数忽略了缺失值

figure

图 4.19 检查带有多个品牌的产品的结果

在我们的目录中有 1,245 个产品,它们要么有两个不同的品牌,要么有时缺少brand列。为了清理这些数据,我们将应用类似于我们用于类别的逻辑:对于有多个品牌的产品,我们选择多数非空品牌。以下代码块实现了此逻辑,输出是产品 ID 到它们应分配的品牌映射。图 4.20 显示了此输出的一个子集:

def get_correct_brand(product_id_rows):
    brand_counts = product_id_rows["brand"].value_counts(dropna=False) #1

    if isinstance(brand_counts.index[0], str):
        return brand_counts.index[0] #2

    if len(brand_counts) == 1:
        return np.nan #3

    return brand_counts.index[1] #4

corrected_brands = (
    events[events["product_id"].isin(duplicated_brands)]
    .groupby("product_id")
    .apply(get_correct_brand)
    .reset_index(name="corrected_brand")
)

corrected_brands.head()

1 product_id_rows 包含一个产品 ID 的所有行,因此我们可以找到与该 ID 关联的品牌。使用 value_counts 将只给出非 NA 值。

2 如果没有 NA 值,则直接返回多数品牌。

3 现在,如果 np.NaN 是唯一值,则返回它。

4 否则,返回第二个值(多数非空值)。

figure

图 4.20 产品 ID 与它们应分配的品牌

类似于我们处理类别的方式,我们可以将这些修正后的品牌与原始事件数据连接起来,并在必要时覆盖brand列。

修正品牌标签的不一致性

我们使用以下代码执行此操作,之后我们验证是否有任何产品 ID 仍然有多个品牌和类别的组合。预期的输出是没有这样的产品 ID,唯一的产品 ID、类别和品牌组合的数量应与唯一产品 ID 的数量相匹配。运行以下代码块不会导致断言错误,这意味着条件得到满足,产品 ID 最终以独特的方式分类:

events = events.merge(corrected_brands, on="product_id", how="left")
events.loc[events["corrected_brand"].notnull(), "brand"] = \
    events.loc[events["corrected_brand"].notnull(), "corrected_brand"]

assert (
    len(events[["product_id", "category", "subcategory", "brand"]]
        .drop_duplicates())
    ==
    events["product_id"].nunique()
)

在项目的剩余部分,我们将处理产品级数据,因此一个有用的步骤是为每个产品赋予一个更具描述性的名称,尤其是因为我们没有“产品名称”列。每个产品 ID 应该有一个唯一名称,这意味着产品 ID 应该是名称的一部分,因为这是我们数据集中产品独特性的原因。我们可以将产品的 ID、品牌和类别代码组合起来,为每个产品得到一个更易读的字符串。以下代码执行此操作,并显示了包含此附加列的数据输出,如图 4.21 所示:

def get_product_name(row):
    brand = ""

    if isinstance(row["brand"], str):  #1
        brand = row["brand"]

    return f"{str(row['product_id'])} - {brand} {row['subcategory']}" 

events["product_name"] = events.apply(get_product_name, axis=1)

events.head()

1 仅当品牌可用时包含品牌。

figure

图 4.21 新增到事件数据中的列的快照

最后,我们可能希望将此修改后的数据导出到一个中间数据集中,我们可以使用它进行分析。由于数据量较大,一些操作可能需要几分钟,我们不希望每次回到分析工作时都成为瓶颈。

将中间数据模型导出以将数据清洗与分析分离

将清理数据的代码与分析数据的代码分开是一种良好的做法。一种方法是将清理后的数据输出到单独的文件中,分析代码可以直接读取。

中间数据可以是任何格式,但parquet很有用,因为它是一种压缩的数据格式,它存储类型信息,与 CSV 等其他格式不同。这意味着当我们未来读取 parquet 文件时,我们不需要将日期列转换为日期类型,因为我们的代码已经知道它们:

events.to_parquet("./data/events.parquet.gz", compression="gzip")

在继续之前,让我们总结一下这一部分的分析。图 4.22 显示了到目前为止我们所采取的步骤,直到我们导出我们的中间数据集。请注意,我们对类别和品牌的调查是并排表示的,因为它们是相互独立的,可以按任何顺序完成。

图

图 4.22 分析第一部分的所有步骤

现在,我们准备继续进行解决方案的第二部分,即总结数据到产品级别并计算所有相关指标。

4.4.2 计算产品级指标

我们已经探索并清理了我们的数据,现在准备开始查看我们想要的指标。然而,首先我们需要将数据转换到正确的粒度。

改变数据的粒度以适应问题

原始数据在事件级别,而我们感兴趣的是产品,因此我们应该将数据聚合,使每行对应一个产品。在这样做的时候,我们需要定义各种聚合来在正确的级别上总结数据。为了了解我们需要哪些聚合,我们必须精确地定义我们的指标。根据我们的项目概述,我们将计算的指标是

  • 体积 — 指定产品 ID 的购买事件数量。我们没有“数量”列,这意味着如果某个用户购买了同一产品两次,数据中会有两行分别表示这一情况。因此,通过计算对应购买事件的行数,我们可以统计产品的销售量。

  • 收入价格列的总和。我们没有关于运费、产品的批发成本或税额的信息,因此我们只能计算每个产品的毛总收入。

  • 受欢迎程度 — 购买产品的独特用户数量。这将忽略单个用户购买多个产品的情况,但可以作为受欢迎程度的良好代理。

  • 性能变化 — 对于这一点,我们将简单地分别计算 11 月和 12 月事件中的每个指标。

  • 转化率 — 给定数据,它将被定义为在给定期间内购买事件作为购物车事件的百分比。也就是说,在所有有人将商品放入购物车的情况下,有多少百分比变成了购买事件?理想情况下,我们也会考虑有多少百分比查看的商品变成了销售,但我们没有数据来回答这个问题。

转化率是一个棘手的指标,因为分母可以有多种解释方式。例如,如果用户将一个项目放入购物车,然后移除它,再次添加,并最终购买,我们可能会有两个事件贡献给分母而不是一个。我们是否将这些事件作为单个实例去重或使用两行将影响我们的转化率。在这里,在计算转化率时,我们将保持简单,将所有购物车事件作为我们的分母,所有购买事件作为我们的分子。

考虑到我们感兴趣的指标类型,我们在产品级别的聚合将包括

  • 11 月和 12 月的数据分别进行月度比较

  • 收入的价格列总和

  • 行数计数以衡量数量

  • 独特用户数量以衡量受欢迎程度

  • 每月数字之间的差异

  • 购物车和购买事件的次数,以便我们可以计算转化率

对于除了转化率以外的所有指标,我们只需要购买事件。只有在引入转化率时,我们才想查看购物车事件。因此,为了使我们的代码更高效,我们将在单独计算转化率指标并将其加入之后,在购买事件之前计算大多数指标。

要做到这一点,我们需要将购物车事件数据与购买事件数据分开:

purchases = events[events["event_type"] == "purchase"].copy()
carts = events[events["event_type"] == "cart"].copy()

现在,让我们逐步构建一个按产品级别的指标联赛表(排名系统)。

同时计算多个指标

创建这个联赛表将是一段密集的代码,因为这是我们进行更多计算的地方,所以让我们将其分解为其单独的步骤。首先,我们创建新的指标列来计算月度值。指标是包含值或回退值(如 0)的列,如果数据行不符合所需的条件。在这种情况下,november_revenue将包含 11 月份事件的价格列的值。否则,它将包含 0。因此,计算给定产品 ID 此列的总和将只给我们 11 月份的总收入。以下代码创建了指标列:

purchases
    .assign(
        november_count=np.where(purchases["event_time"].dt.month==11,
↪ 1, 0),
        november_revenue=np.where(purchases["event_time"].dt.month==11, 
↪ purchases["price"], 0),
        november_user_id=np.where(purchases["event_time"].dt.month==11, 
↪ purchases["user_id"], np.nan),
        december_count=np.where(purchases["event_time"].dt.month==12,
↪ 1, 0),
        december_revenue=np.where(purchases["event_time"].dt.month==12, 
↪ purchases["price"], 0),
        december_user_id=np.where(purchases["event_time"].dt.month==12, 
↪ purchases["user_id"], np.nan))

接下来,我们按产品 ID 分组我们的数据,并总结这些新的指标列以获取月收入、行数和唯一用户 ID 计数:

.groupby(["product_id", "product_name"])
    .agg(november_volume=('november_count', 'sum'),
         november_revenue=('november_revenue', 'sum'),
         november_users=('november_user_id', 'nunique'),
         december_volume=('december_count', 'sum'),
         december_revenue=('december_revenue', 'sum'),
         december_users=('december_user_id', 'nunique')
    )

最后,我们创建新的列来计算 11 月和 12 月数字之间的差异。这给我们所有所需的指标,这些指标只需要购买事件数据。这就是我们将加入我们的转化率指标的数据库。以下是最终的完整代码块,图 4.23 显示了创建的联赛表的快照:

purchases_league_table = (
    purchases
    .assign(
        november_count=np.where(purchases["event_time"].dt.month==11,
↪ 1, 0),
        november_revenue=np.where(purchases["event_time"].dt.month==11, 
↪ purchases["price"], 0),
        november_user_id=np.where(purchases["event_time"].dt.month==11, 
↪ purchases["user_id"], np.nan),
        december_count=np.where(purchases["event_time"].dt.month==12, 
↪ 1, 0),
        december_revenue=np.where(purchases["event_time"].dt.month==12, 
↪ purchases["price"], 0),
        december_user_id=np.where(purchases["event_time"].dt.month==12, 
↪ purchases["user_id"], np.nan))
    .groupby(["product_id", "product_name"])
    .agg(november_volume=('november_count', 'sum'),
         november_revenue=('november_revenue', 'sum'),
         november_users=('november_user_id', 'nunique'),
         december_volume=('december_count', 'sum'),
         december_revenue=('december_revenue', 'sum'),
         december_users=('december_user_id', 'nunique')
    )
    .assign(
        volume_diff=lambda x:
            x["december_volume"] - x["november_volume"],
        revenue_diff=lambda x:
            x["december_revenue"] - x["november_revenue"],
        users_diff=lambda x:
            x["december_users"] - x["november_users"])
    .reset_index()
)

purchases_league_table.head()

figure

图 4.23 购买联赛表数据的快照

从这张表中,我们可以一目了然地获取大量信息,包括

  • 11 月份比 12 月份销售更多,反之亦然的产品

  • 11 月份没有销售但在 12 月份销售的产品

  • 比较每月购买产品数量多或少

在某些方面,我们甚至可以在这里停下来,通过根据我们认为代表最佳产品指标的列对这张表进行排序来简单地使用这张表来回答我们的问题。然而,我们还想首先计算转换指标。以下代码计算了每个月每个产品 ID 每个事件类型的行数:

conversion_table = (
    pd.pivot_table(
        data=events.assign(month=events["event_time"].dt.month),
        index=["product_id", "product_name"],
        columns=["month", "event_type"],
        values="user_session",
        aggfunc="count"
    )
    .fillna(0)
    .set_axis(labels=["november_cart", "november_sold",
↪ "december_cart", "december_sold"],
              axis=1)
    .reset_index()
    .assign(november_conversion
↪ = lambda x: x["november_sold"] / x["november_cart"],
            december_conversion
↪ = lambda x: x["december_sold"] / x["december_cart"])
)

conversion_table.head()

使用pandas中的pivot_table函数意味着这个表可以扩展到计算任何数量的事件类型,如果我们要获取查看事件的数据,也可以使用。这在这里不是严格必要的,但它需要一点预防性的编码来为未来的情况做准备。图 4.24 显示了这个转换联赛表的快照。

figure

图 4.24 转换联赛表数据的快照

从这个快照中,我们已经发现了一个问题;这些数据将包含缺失值。没有购物车或购买事件会导致缺失值,而不是零,因为严格来说,0 转换意味着我们有了购物车事件,但没有一个导致了购买。每次你计算除法时,你也应该准备好遇到无穷大的值,这发生在你尝试除以零时。

目前,让我们合并这两个产品联赛表,并确保产品 ID 是唯一的。图 4.25 显示了当我们查看这个合并的指标数据集的列时的输出:

league_table = purchases_league_table.merge(conversion_table,
↪ on=["product_id", "product_name"], how="left")
assert len(league_table) == purchases["product_id"].nunique()
league_table.dtypes

figure

图 4.25 我们的指标表中的列及其数据类型

让我们将这些最新的步骤添加到我们不断增长的图中,以展示我们的分析步骤。图 4.26 显示了最新版本。我将产品品牌和类别的调查并排放置,以表明它们是独立发生的,并且不一定按照特定的顺序依次进行。

figure

图 4.26 到目前为止采取的步骤,直到创建指标联赛表

现在,我们有了所有我们可能感兴趣的指标,下一步是查看表现良好的产品。

4.4.3 使用我们定义的指标寻找最佳产品

在所有这些指标都到位的情况下,我们如何使用它们来找到“最佳”产品?答案是,正如通常情况下那样,这取决于。

根据具体问题定义指标

我们有多种选择,例如

  • 选择一个单一指标来对联赛表进行排序,并将前N名产品视为最佳。

  • 我们可以通过每个指标的相对重要性对其进行加权,创建一个加权指标值的组合,以得出一个单一值来定义每个产品,并使用该值来找到前N名。

  • 如果我们特别关注 12 月份的表现者,我们可以查看差异指标中的异常值,例如 11 月和 12 月之间的数量差异。

选项还有很多,但由于简要报告特别关注的是 12 月份的销售期,我们将集中精力在那些月销量增长最大的产品上。我们应该关注哪些指标来衡量这种差异?我们的选项有

  • 收入变化 — 关注绝对收入可能会使贵重商品扭曲我们的结果。

  • 唯一用户数量变化 — 这可能是一个衡量产品月度表现良好的良好指标。

  • 销量变化 — 这似乎是一个坚实、合理的起点,因为仅仅每月销量增加就是一个良好表现的指标。

我们首先应该了解典型月份间的销量变化。图 4.27 显示了以下代码生成的直方图:

fig, axis = plt.subplots()

league_table["volume_diff"].hist(bins=100, ax=axis)

axis.set(title="Distribution of month-on-month change in volume",
         xlabel="Change in volume from November to December",
         ylabel="Frequency")

figure

图 4.27 产品月销量变化的直方图

显然,存在异常值,这些异常值使这个直方图失去了实用性。让我们放大中间部分,看看月销量变化是否围绕零中心。以下代码生成了图 4.28 中的直方图:

fig, axis = plt.subplots()

league_table.loc[league_table["volume_diff"].between(-100,100),
↪ "volume_diff"].hist(bins=100, ax=axis)

axis.set(title="Distribution of month-on-month change in volume",
         xlabel="Change in volume from November to December",
         ylabel="Frequency")

figure

图 4.28 图 4.27 的放大版本

仔细观察这个直方图,我们发现大多数产品的月销量变化不大,有些产品销量增加,而有些产品销量减少。然而,这些都是绝对值。每月通常只卖出少量商品的产品在这些图表中可能被低估。因此,我们实际上应该计算并查看销量变化的百分比。以下代码计算了这个值,并生成了如图 4.29 所示的百分比变化直方图。请注意,代码还移除了缺失的百分比变化值,以及除以零时产生的无穷大值:

league_table["volume_diff_pct"] = (
    100 * (league_table["volume_diff"] / league_table["november_volume"])
)

fig, axis = plt.subplots()

(
    league_table["volume_diff_pct"]
    .replace([np.inf, -np.inf], np.nan)
    .dropna()
    .loc[lambda x: x.between(-101,501)]
    .hist(bins=50, ax=axis)
)

axis.set(
    title="Distribution of month-on-month percentage change in volume",
    xlabel="% difference between sales in November and December",
    ylabel="Frequency"
)

figure

图 4.29 销售量月度百分比变化的分布

此图表显示,与 11 月相比,大多数产品在 12 月实际上没有卖出任何东西,因为-100%的变化意味着 12 月没有卖出任何商品。也有一些产品月销量增加了两倍、三倍、四倍和五倍。使用百分比值时,我们需要注意可能出现的偏差。例如,如果我们 11 月卖出了一件产品,12 月卖出了五件,这是 500%的变化,但可能不像在上一月只卖出 500 件时卖出 1000 件那样具有重大意义,尽管从技术上讲这是一个更小的百分比变化。减轻这种问题的一种方法是不包括那些通常只卖出少量商品的项目。

在我们继续分析之前,我们应该在我们的排行榜中包含更多的产品细节,这样当我们识别出高绩效产品时,我们可以查看它们代表哪些类别或品牌。

根据我们的发现迭代我们的指标

我们本可以在创建排行榜时使用这些额外数据来增强我们的产品,但我们现在在分析的这个阶段这样做是为了响应我们进一步调查的愿望。以下代码创建了一个产品目录,我们可以将其与排行榜连接以添加额外的产品细节。图 4.30 显示了该产品目录的快照:

product_catalog = (
    events[["product_id", "product_name", "category_id", "subcategory",
↪ "brand", "category"]]
    .drop_duplicates(subset=["product_id", "product_name", "subcategory",
↪ "brand", "category"])
)

assert len(product_catalog) == events["product_id"].nunique()

print(product_catalog.shape)
product_catalog.head()

figure

图 4.30 产品目录快照

现在,我们通过定义特定产品销售数量作为寻找顶级表现者的截止点来继续我们的分析。排除每次只售出少量产品的产品意味着高百分比变化值更有可能是有意义的:

DEC_VS_NOV_PCT_CUTOFF = 200
NOV_VOLUME_CUTOFF = 10
ONLY_DEC_VOLUME_CUTOFF = 100

december_high_performers = (
    pd.concat(
    [
        league_table[(np.isinf(league_table["volume_diff_pct"]) == False)
            & (league_table["november_volume"] > NOV_VOLUME_CUTOFF)
            & (league_table["volume_diff_pct"] > DEC_VS_NOV_PCT_CUTOFF)],
        league_table[(np.isinf(league_table["volume_diff_pct"]))
            & (league_table["december_volume"] > ONLY_DEC_VOLUME_CUTOFF)]
    ],
    axis=0,
    ignore_index=True)
    .merge(product_catalog.drop(columns="product_name"), on="product_id")
)

print(december_high_performers.shape)

在这里,我们定义了一些截止点,这意味着我们只考虑那些

  • 在十二月售出的数量是十一月的两倍以上

  • 在十一月至少售出 10 件

  • 在十二月至少售出 100 件

这些截止点有些是任意选择的,改变它们将会影响我们的结果,但它们确实确保我们只识别出在十二月真正超越自己的产品。我们特定的截止点产生了 449 个产品。我们可以直接将这些产品提供给我们的利益相关者,或者我们可以进行进一步的分析,看看哪些类型的产品更频繁地出现在高绩效者名单中。让我们来看看这些高绩效产品的顶级类别、子类别和品牌。这些分别显示在图 4.31 至图 4.33 中:

from IPython.display import display

for col in ["category", "subcategory", "brand"]:
    print(col)
    display(december_high_performers[col].value_counts())

figure

图 4.31 顶级产品类别

figure

图 4.32 顶级产品子类别

figure

图 4.33 顶级产品品牌

从结果中可以看出,智能手机、各种服装和 Lucente 制造的产品在十二月的最佳表现者排名中位居前列。还有一些咖啡研磨机似乎表现良好。实际上,更深入地挖掘结果表明,这些咖啡研磨机占 Lucente 在排行榜上成功的大部分。以下代码显示了如图 4.34 所示的类别、子类别和品牌的顶级唯一组合:

december_high_performers[["category", "subcategory", "brand"]]
↪ .value_counts().head(10)

figure

图 4.34 按类别和品牌划分的顶级产品

最终表格中的一个可能引起注意的方面是索尼制造的鞋子的存在,这可能需要一些调查。它们可能是真正的索尼品牌鞋子,或者是不正确分类的产品。

我们现在有足够的结果可以向利益相关者展示。为了回顾,我们有

  • 合并了事件数据集

  • 清理了错误分类的产品数据

  • 计算了所有感兴趣的可能的指标

  • 决定将什么作为“最佳”的衡量标准

  • 根据我们选择的指标提取了最高绩效的产品

  • 对这些高绩效者进行了更详细的研究

无论您选择哪个(些)度量,过程都将与这个示例解决方案中显示的过程相似。我们不太可能得出相同的结论,因为这些结论完全取决于您在整个分析过程中的选择。

让我们简要看看当我们使用不同的度量来定义最佳产品时会发生什么,以及这如何影响我们的结果。

调查替代的度量定义

如果我们决定最佳产品是那些在 12 月份将最多购物车事件转化为购买事件的,会发生什么?首先,我们使用以下代码生成图 4.35 中的直方图,显示 12 月份的转化率分布:

fig, axis = plt.subplots()

(
    league_table[(np.isinf(league_table["december_conversion"]) == False)
↪ & (np.isnan(league_table["december_conversion"]) == False)]
    ["december_conversion"]
    .mul(100)
    .hist(bins=50, ax=axis)
)

axis.set(
    title="Distribution of December conversion",
    xlabel="Conversion (%)",
    ylabel="Frequency"
)

图

图 4.35 12 月份转化率分布

大多数产品的转化率大约在 30%左右,但有很多产品的转化率是 100%,甚至更多。后者理论上是不可能的,因为我们逻辑上不应该有比放入购物车的商品更多的购买。让我们调查这些记录。以下代码仅提取转化率超过 99%的行,并排除无限值和其他数据错误。使用这些结果,我们检查了一个高转化率的单个示例,如图 4.36 所示:

(
    league_table[(np.isinf(league_table["december_conversion"]) == False)
                 & (np.isnan(league_table["december_conversion"]) == False)
                 & (league_table["december_conversion"] > 0.99)]
    .sort_values("december_conversion", ascending=False)
    .head(20)
)
events[(events["product_id"] == 9200694)
↪ & (events["event_time"].dt.month == 12)]

图

图 4.36 导致超过 100%转化率的原始数据示例

有一个围巾,同一个用户在两个不同的日期购买了三次,但只关联了一个购物车事件。这可能意味着我们遗漏了两个购物车事件的数据错误,或者我们对数据生成过程做出了错误的假设。当我们说转化率不应超过 100%时,我们假设了一个线性过程,其中用户在将商品放入购物车之前不能进行购买。然而,可能存在一个“订单”页面,用户可以查看他们的过去订单并重新订购产品,同时绕过购物车屏幕。如果情况如此,图 4.35 中显示的数据就不会令人意外。

如此看来,我们无法知道这些实例是否有效,因此为了得到我们的顶级表现者,我们可以简单地限制我们的数据在 100%以下转化率。让我们还规定一些截止点,并说一个产品必须至少被N个用户购买,总销售量达到M,才能包括在我们的顶级转化表现者中。以下代码确定了顶级表现者,总共有 55 行:

MIN_USERS = 5
MIN_PURCHASES = 10
CONVERSION_LOWER_LIMIT = 0.7

best_december_converters = (
    league_table[(np.isinf(league_table["december_conversion"]) == False)
             & (np.isnan(league_table["december_conversion"]) == False)
             & (league_table["december_conversion"]
↪ .between(CONVERSION_LOWER_LIMIT, 1))
             & (league_table["december_users"] > MIN_USERS)
             & (league_table["december_sold"] > MIN_PURCHASES)]
    .sort_values("december_conversion", ascending=False)
    .merge(product_catalog.drop(columns=["product_name"]),
↪ on=["product_id"])
)

print(best_december_converters.shape)

这意味着总共有 55 个产品至少被五个不同的用户购买,总销售量至少为 10,并且将超过 70%的购物车事件转化为购买事件。现在我们可以通过查看它们的类别来查看这些产品对应的是什么。这个输出在图 4.37 中显示:

best_december_converters["category"].value_counts()

图

图 4.37 最高转化率产品类别

结果与基于体积的排名大相径庭。当考虑转换率为关键指标时,家具和建筑用品成为表现最好的类别。进一步分析,使用以下代码可以得到图 4.38 所示的结果:

best_december_converters.loc[best_december_converters["category"]
↪ .isin(["furniture", "construction"]), "subcategory"].value_counts()

figure

图 4.38 12 月份转换率最高的家具和建筑产品

这告诉我们,一旦大多数人已经识别了一个水龙头、钻头或沙发,他们很可能会继续购买。智能手机在基于转换率的排名中占比较少,这可能表明即使在将商品放入购物车后,人们对智能手机的购买也可能不确定。

在总结我们的结论之前,让我们以图表的形式回顾整个分析过程,以便提醒自己采取的步骤以及我们的选择可能出现的分歧。图 4.39 展示了最终的图示。

figure

图 4.39 显示我们分析中所有步骤和决策点的最终图示

我们现在已准备好总结我们的结果,以便向我们的利益相关者展示。

项目成果

根据这些结果,向我们的利益相关者推荐经典圣诞礼物,如智能手机和厨房电器,在 12 月份可能表现良好。基于转换率的结果说服力较弱,因为它们只突出了人们在将产品添加到购物车后购买的产品,但它们可能不会告诉我们关于这些产品成功与否的任何信息。

与以往一样,我们的分析存在局限性。我们没有考虑产品价格与性能之间的相关性。我们的数据可能隐含地告诉我们何时有促销活动,我们可以通过观察价格突然下降的产品来识别这些促销活动,这可以为我们决定在促销期间哪些产品销售最好提供信息。我们还可以请求额外的定价数据,例如邮费、税费或商品批发成本。这个数据可以告诉我们哪些产品创造了最多的收入,也可以告诉我们哪些产品创造了最高的利润。

我们仅限于两个月的数据也意味着我们的分析是不完整的。拥有更多数据将使我们能够更好地基准测试我们的产品,并看到哪些产品的表现与平均基线性能差异最大。目前,我们基于一个月的数据进行分析,这可能是不足够的。

我们最初的发现需要与我们的利益相关者进行进一步讨论,并且,希望向他们展示选择不同指标的影响能帮助他们关注真正重要的指标。在选择向他们展示的内容时,考虑我们的建议是否可行很重要。告诉利益相关者人们通常购买他们已经识别的水龙头、钻头或沙发,这不太可能促使他们采取行动,而告诉他们智能手机在 12 月期间最能增加他们的销售额则更有可能促使他们采取行动。

活动:使用这些数据的进一步项目想法

本章中的电子商务数据为你提供了大量练习计算和反思不同指标的机会。与所有项目一样,我建议考虑不同的研究问题,例如

  • 哪些产品的价格波动比其他产品更大?没有理由假设同一产品 ID 每次都以相同的金额出售。

  • 数据中是否存在时间模式(例如,周末比工作日销售更多或转化更好的产品)?

  • 你可以深入产品目录,寻找表现优于其他子类别的子类别。这里的“更好”意味着什么?

4.5 关于指标的结束语

作为一名分析师,你将遇到许多类似的项目,其中指标可能定义不足。你的工作不仅仅是执行利益相关者给出的计算,而是与他们进行对话,定义在现有数据下清晰可衡量,并能捕捉到正确的基本概念的指标。我们通常应该对将复杂问题简化为单一指标持谨慎态度,并且应该感到舒适地拒绝,以帮助利益相关者创建更可用的问题定义。

有两条途径可以进一步探索这些概念:一条是了解不同的商业指标通常,以便使你更熟悉商业术语;另一条是研究你所在行业的特定指标。

你如何知道该阅读哪些关于这个主题的内容?事实证明,为新的主题生成阅读列表是生成式 AI 工具的绝佳用途,例如 Open AI 的 ChatGPT 这样的大型语言模型。我给出了以下提示:

给我一份阅读列表,供对探索不同商业指标及其影响感兴趣的人阅读

ChatGPT 能够生成一份阅读列表,以进一步探索指标。该列表包括像罗伯特·S·卡普兰和戴维·P·诺顿所著的《平衡计分卡:将战略转化为行动》(哈佛商学院出版社,1996 年)这样的通用作品,该书“超越了财务指标,以衡量组织的各个方面绩效”,或者专注于更具体领域,如社交媒体的《衡量什么重要:在线工具理解客户、社交媒体、参与度和关键关系》(威利出版社,2011 年)凯蒂·德拉海耶·佩恩所著的标题。

虽然 AI 提出的所有建议可能并不都能使用,但将其用于启动研究过程是它成为分析师工具箱中一项有价值的工具的一种方式。

4.5.1 定义任何项目更好指标所需的技能

在本章中,我们最大的挑战是将模糊的要求转化为我们可以分析的衡量指标。适用于任何类似项目的关键技能包括

  • 确保数据中的定义是一致的(例如,同一产品始终被分配到同一类别)

  • 将数据的中级版本导出,以便将探索和清洗与分析分离

  • 改变数据的粒度以适应问题(例如,将交易级别的数据汇总到产品级别)

  • 计算多个指标以从不同角度研究问题

  • 在获得一些初步发现后,迭代我们选择的指标

  • 调查替代指标,以便向我们的利益相关者展示更全面的图景

摘要

  • 选择合适的指标是分析师发展的重要专业技能。

  • 你选择的任何用于衡量性能的指标都将影响整个分析路径。

  • 谨慎将复杂问题简化为单一指标。

  • 关键指标的定义应当明确,以避免分析错误。

第五章:5 种不寻常的数据源

本章涵盖

  • 考虑数据不仅仅是结构化格式中可用的数据

  • 创造性地使用你所能获得的所有数据源,无论其格式如何

  • 在使用额外数据源时,在花费的时间和增加的价值之间进行权衡

你在职业生涯中遇到的大多数数据集都不像学习环境中提供的那样干净和结构化。现实情况是,通常分析师必须寻找正确的数据,这些数据可能隐藏在复杂的电子表格中,甚至更隐藏在非结构化、非传统数据源中。本章是关于练习识别和使用新颖的非结构化数据源来回答有趣的统计分析问题的创造力。

结构化数据与非结构化数据

为了清晰起见,当我使用“结构化”和“非结构化”这些词来描述数据集时,我的意思是表格数据,二维数据与所有其他数据的对比。分析师通常处理结构化数据——可以在 Excel 中打开的具有行和列的数据,或者位于数据库中,理论上可以在 Excel 中打开的数据。非结构化数据是任何不是行和列格式的数据,范围从文档或原始音频到自由文本或二进制数据格式。

在这个项目中,你将处理包含结构化数据表的未结构化 PDF 文件。我们称这些数据为未结构化、结构化、半结构化或其他,其语义不会改变这样一个事实:处理 PDF 与处理表格数据并不相同。这是我们本章中处理的结构化与非结构化差异。

5.1 识别新型数据源

我总是主张从要解决的问题开始。在考虑用于分析的其他数据源时,这也没有什么不同。一旦你有一个明确的问题陈述,就更容易理解你还需要哪些数据源。这就是为什么识别和获取数据是结果驱动方法中的第 3 步和第 4 步,而不是第 1 步和第 2 步。

你能获得的数据将在不同工作场所之间有所不同,但通常,可能有助于考虑的数据类型包括

  • 由典型业务流程生成数据,例如电子邮件。

  • 运营系统中的数据,如果尚未可用。

  • 自托管数据,意味着人们为自己创建的数据,例如人们电脑桌面上的电子表格。这些数据只有在人们依赖它们进行决策时才重要。否则,它们可能只是现有运营数据的更不准确版本。一个例子是销售人员在公司 CRM 之外跟踪自己的客户管道。

  • 行业数据,例如由中央机构发布的市场统计数据。

  • 政府统计数据,作为公开数据发布。

  • 白皮书,公开文件,以易于理解的方式总结特定主题的研究,这些文件可能由内部创建或由竞争对手创建。

真实业务案例:从 PDF 中提取已发布的行业数据

在许多行业中,领先的行业机构发布市场统计数据,这些数据通常是衡量市场状况随时间变化的最佳指标。许多这些统计数据以表格数据的形式发布,通常是 Excel 文件。然而,在过去,我不得不求助于寻找不那么结构化的重要统计数据,并编写自己的 PDF 数据提取代码。这是每位有抱负的分析师都应该经历的经历,因此包含了这个项目。

5.1.1 使用新数据集的考虑因素

在决定使用额外的数据源来增强分析时,有一些一般性的考虑因素:

  • 这个数据源是否与现有数据集成?我们能否将这个数据集与我们已经使用的那个合并,或者这并不是必需的?有些情况下,销售人员会在自己的电子表格中记录销售情况,无论是在“官方”CRM 系统之外还是在旁边。是否有可能将他们自己的自定义电子表格中的数据与 CRM 系统中的数据合并?这两个数据集中是否存在一个共同的客户标识符,比如 ID?如果没有,我们是否还能通过某种方式将客户在这两个数据集中联系起来,比如通过姓名?请参考第三章,以了解这样一个具体问题的例子。

  • 从这个数据源中提取结构化数据需要多少努力?这可能涉及操作非结构化格式并创建表格表示,或者取一个格式与我们的现有数据不同的结构化数据集,因此需要工作来更改和重命名列以匹配我们需要的格式。无论如何,在决定使用新的数据源之前,估算这项工作的努力程度是很重要的:

    • 这个问题的子集之一是,你目前是否具备操作这些数据的专业知识?没有与特定数据格式合作的经验并不是一个不可逾越的障碍,但学习必要的技能是估算所涉及努力的一个因素。

    • 相关的考虑因素之一是,你的工具是否支持这种数据格式?例如,如果你习惯于仅使用 Excel 进行工作,那么处理不寻常的数据格式可能会更困难,但像 Python 或 R 这样的编程语言可能有一个易于安装和使用的相关库。

  • 这额外数据的价值是什么?它能回答哪些你之前无法回答的问题?了解这一点将有助于确定这些努力是否值得。

  • 这数据是否创建了额外的依赖?这些额外数据是一次性使用,还是需要工程资源来持续摄取和存储的东西?

5.2 项目 4:使用 PDF 数据分析电影行业趋势

让我们看看这个项目,我们将从 PDF 文件中提取结构化数据,以了解 COVID-19 大流行对电影行业的影响。我们将研究我们的利益相关者想要解决的问题以及他们提供的数据来源。第 5.3 节将深入探讨如何使用结果驱动的方法来解决这个问题,以及处理 PDF 文件时的一些技术考虑。与每个项目一样,有一个专门用于逐步示例解决方案的部分,可在第 5.4 节找到。像往常一样,我们的解决方案可能会不同,尤其是如果您不使用 Python,因为我探索了一些从 PDF 中读取数据表的 Python 特定方法。

数据可在davidasboth.com/book-code找到。您将找到可用于尝试项目的文件,以及以 Jupyter 笔记本形式的示例解决方案。

5.2.1 问题陈述

在这个场景中,您为 EchoTale Analytics 工作,这是一家娱乐行业的研究公司。他们的主要任务是发布关于娱乐行业演变的分析文章,您被分配到他们的电影部门负责一个项目。具体来说,公司希望发布一份关于 COVID-19 大流行如何影响电影行业的白皮书。他们目前还没有一个更具体的主题,因此您的任务是完成并展示初步研究。由于目标是白皮书,优先考虑的是能够讲述一个对电影爱好者有吸引力的故事。

该公司仅与外部数据源合作,对于这个项目,他们给了您来自英国电影行业(BFI)研究统计单位(RSU)的 PDF 报告,称为统计年鉴。这些年鉴包含了关于电影行业的年度统计数据摘要,包括嵌入的数据表。大部分数据与英国的电影行业相关,但也包括一些全球统计数据。数据可以追溯到近 20 年前,自然地,PDF 报告的格式并不一致。

备注:感谢 BFI RSU 以及具体的高级研究与分析员 John Sandow 允许使用 PDF 报告。

要完成这个项目,您需要

  • 确定在年鉴中分析电影行业的维度

  • 决定提取数据的范围,以及构成 COVID-19 前和封锁后时期的内容

  • 提取必要的底层数据

  • 分析电影统计数据,得出一个对您的利益相关者准备白皮书有用的叙事

您的利益相关者的优先事项是研究结果既有趣又出乎意料。他们已经假设在疫情期间电影院入场人数下降,在封锁期间降至零,他们不希望发布一份包含如此明显统计数据的白皮书。他们更希望您探索以下内容:

  • 不同的国家在封锁后恢复情况是否不同?

  • 在大流行前后,哪些电影类型流行?自封锁限制解除以来,这种变化了吗?

  • 哪些发行商在大流行后经历了最大的变化?

  • 人们对待独立电影的态度是否有所改变?

你将在分析部分的大部分时间探索封锁前后的趋势,并将它们进行比较,以识别最显著的变化,这些变化最有可能引起你的利益相关者的兴趣。

5.2.2 数据字典

该项目没有明确的数据字典,这在现实世界中是一个常见问题。即使你没有创建数据字典,你也需要在决定关注哪些数据之前,记下每本年鉴中存在的数据类型。数据的一些方面可能只存在于较老或较新的年鉴中,但你需要确保你的数据在所有你最终使用的文档中都能一致地可用。

文档的一个方面将使你的工作变得容易一些,那就是数据在表格中,可以使用适当的工具提取。图 5.1 展示了这样一个表格的例子。

figure

图 5.1 来自统计年鉴 PDF 文件的一个示例数据表
活动:创建数据字典

尝试为最终使用的数据编写数据字典。通常,分析师为最终用于分析的数据编写字典,因为他们是第一个使用它的人。为未来的数据使用者创建此文档是一个好习惯,包括未来的你!

5.2.3 预期成果

你的分析输出应该是关于白皮书潜在主题的建议。建议应该是具体的,所以如果你认为自封锁限制解除以来,人们更喜欢不同电影类型的故事,你的分析应该包含关于哪些类型在大流行前后流行的具体结论。你的建议还应得到可视化支持,这些可视化可能包含在最终的白皮书中。作为一个额外的考虑,你也可以考虑你的数据提取方法,无论是代码还是特定的工具,是否可以用于该项目的未来版本。

5.2.4 必需的工具

对于本章的示例解决方案,我使用了 Python 库 pandasmatplotlib 来探索和可视化数据。最初从 PDF 文件中提取数据时,我最终使用了 Python 库 pdfplumber,但我将讨论其他选项。你选择的工具可能不同,并且与每个项目一样,工具的重要性不如过程,但你选择的工具必须能够

  • 读取 PDF 文件并从中提取表格数据到更典型的格式,例如 CSV 文件

  • 从 CSV 或 Excel 文件中加载多个数据集

  • 合并两个或多个数据集

  • 执行基本的数据操作任务,如排序、分组和重塑数据

  • 创建数据可视化

我选择在这个章节中也保持在我的 Python 工具包内,但我将讨论一些你可供选择的其它选项。你可能会选择使用常规工具包之外的工具从 PDF 中提取数据,在这种情况下,你的常规工具只需要满足后面的要点,而不一定是第一个要点。

5.3 将结果驱动方法应用于从 PDF 中提取数据

这个项目有很多不确定性,部分原因是 PDF 文件的结构未知且不一致,部分原因是利益相关者请求的模糊性。使用结果驱动方法,我们可以制定一个行动计划。

figure

我们的利益相关者并没有给我们太多指导,因此在这个阶段我们对问题的理解还不完整。我们的第一次迭代需要专注于识别年鉴中多年间的共同数据表,并分析我们手头上的资源。一旦我们开始观察疫情前和封锁后的趋势,我们将对分析的主要主题有更多了解。

figure

尽管我们不知道白皮书的具体主题是什么,但我们知道我们需要专注于比较不同时间段的相同数据。我们的最小可行答案将专注于这一比较。即使这些信息也让我们对我们的工作最终输出有了概念,我们可以朝着这个方向努力。

figure

在这个项目中,识别阶段将是关键。这是我们在探索 PDF 并记录下我们可以探索的共同数据主题的地方。一旦我们完成了这些,我们就可以继续提取我们需要的特定数据。这将从长远来看节省我们的时间,因为提取所有数据表然后再探索它们会花费更长的时间。

figure

你可能会认为数据是在我们的利益相关者提供 PDF 文件时获得的,但正如我们所看到的,在我们拥有一个结构化的数据集来探索之前,还有很多工作要做。并非所有遵循结果驱动方法的项目都会在每个部分花费相同的时间。在这个项目中,我预计这一步骤将占据我们大部分的时间。

*figure

让我们勾勒出我们将采取的步骤,基于第 5.2.1 节中讨论的步骤:

  • 首先,我们将按年份降序打开 PDF 年鉴,并记录我们可用的数据表。在打开分析工具之前,实际查看我们的数据是必不可少的,这样我们才能对正在处理的内容有一个大致的了解。

  • 接下来,我们将决定我们要追溯到多远的时间。我们没有很多数据来建立封锁后的趋势,我们也不想花太多时间查看太远过去的数据。

  • 然后,我们可以决定我们将能够从数据中提取哪些方面进行分析。这将取决于我们在确定的时间段内可用的内容。

  • 下一步将是找到并提取我们需要的特定数据表,并将它们保存为结构化格式,例如 CSV 文件。

活动:可重复使用的方法

当你到达这个阶段时,考虑一下你选择的提取方法的可重复使用性。无论你是手动提取数据还是编写脚本来做这件事,都有可能在未来的迭代中再次需要这样做。提前考虑可重复使用性是一个好的做法,可以节省你长远的时间。

  • 在这一点上,我们可以通过检查我们已确定的维度上的疫情前和疫情后的趋势来分析数据,无论是类型的变化、独立电影的招生情况,还是其他方面。

  • 最后,我们将确定一个故事,向我们的利益相关者展示,并将其包含在白皮书中。

*figure

我们无法模拟你展示研究结果时与利益相关者的实际互动。然而,我们可以通过考虑你可能会听到的后续问题来练习准备这样的互动,并准备你的答案。准备好对未来迭代提出建议,可以导致更有效的利益相关者对话。

figure

在这种情况下,我们希望在有一个或多个我们的发现的确凿证据后,尽快向我们的利益相关者展示。构成“确凿”的并不一定是统计意义的问题,而更多的是关于我们的利益相关者会对哪种故事感兴趣并愿意发布的直觉。这项分析,就像其他分析一样,不是你交付的一次性工作;它是一个对话。关于展示什么的想法是分析师技能集的一部分,只能通过沉浸在实际场景中才能发展。

5.4 一个示例解决方案:COVID-19 封锁期间对电影产业的影响

让我们来解决这个问题的示例解决方案。在你自己尝试过这个项目之后,阅读我的解决方案将更有价值。记住,我们的解决方案可能会有所不同。你可能会以不同的顺序完成事情。

我们将首先检查我们的 PDF 文件,看看在多年的时间里哪种数据是一直可用的。一旦我们决定要关注哪些数据集,我们将找到一个 PDF 提取方法,并确保它可以从我们的 PDF 文件中可靠地提取数据表。在第二部分,我们将分析我们新创建的结构化数据,以回答我们利益相关者的一些问题。

5.4.1 检查可用数据

第一步是决定要查看哪些年份的数据。如果我们查看可用的文件,如图 5.2 所示的一个快照,我们会注意到年鉴在 2018 年之前每年只有一个文件,之后文件按类别划分。然而,从 2018 年开始的文件似乎也有一个“主”文档,例如,标题为“2018—BFI 统计年鉴”的那个。

figure

图 5.2 可用 PDF 文件的快照

我们似乎还缺少 2015 年的文件,该文件将包含 2014 年的数据。这是一个我们需要解决的问题,如果我们想追溯到更早的时间。

备注:文件中指出的年份实际上是前一年的,这意味着名为“2018 统计年鉴”的文件包含 2017 年的数据。

我们可以选择稍后扩展我们的时间范围,但就目前而言,我们将目标设定为获取我们所需的最小数据量,以了解封锁前后的趋势。第一次 COVID 封锁是在 2020 年初,所以我们至少需要 2019 年的数据。由于我们感兴趣的是模式,更多的数据会更好,所以我们将包括 COVID 之前的两年:2018 年和 2019 年。这也意味着我们只包括文档格式一致的年份。

长期回顾会给我们更多信息,但我们想平衡时间和复杂性,所以两年将足够我们第一次迭代。如果我们追溯到 2017 年之前,我们想要确保单个文档中的类别与从 2018 年开始分开的文件中的类别相匹配。也就是说,是否有关于观众、发行、公共投资等方面的数据?

2021 年大多数封锁限制都放宽了,所以那之后的 everything 都将是“后封锁”时期,我们需要决定如何对 2021 年的数据进行分类。

由于我们希望我们的数据从 2018 年开始,我们将从 2019 年年鉴开始。让我们看看有哪些数据表可用。查看目录,部分内容如图 5.3 所示,我们看到子标题与 2019 年的单个文件相匹配,这表明所有必要的数据可能都包含在这个单一文件中。

figure

图 5.3 2019 年鉴目录的部分视图

该文件包含文本、图表、信息图表和数据表的混合。

确定要提取的数据

查看数据表,仅就入场人数而言,我们有以下统计数据:

  • 按国家划分的总入场人数

  • 英国每月入场人数

  • 英国地区入场人数

  • 回到 1935 年的年度入学人数

此外,我们还有关于以下广泛类别的数据

  • 总票房收入

  • 年度热门电影

  • 原产国

  • 类型

  • 导演

  • 独立电影

  • 发行商

即使只是查看这些数据表也激发了许多分析方向。由于这些数据是为白皮书准备的,我们应该选择可能包含有趣故事的类别。当然,我们事先不知道,但直觉和领域专业知识可以帮助我们选择更有可能产生结果的路径。这种直觉只有通过大量的实践才能建立。我们将关注

  • 招生模式

  • 类型分布

  • 发行商之间的市场份额

选择这些类别意味着我们可以询问季节性模式是否随时间而改变,现在人们是否更喜欢不同的类型,以及是否有任何发行商在市场份额方面成为疫情后的赢家。

让我们总结一下到目前为止我们所做的工作,因为我们刚刚达到了第一个关键决策点。图 5.4 显示了当前步骤和替代选项。

figure

图 5.4 分析的第一步和决策点

现在,我们可以准确地识别出在所有文件中应该查找哪些表格,以确保我们每年都有正确的数据。为了简单起见,让我们限制每个类别只有一个数据表。图 5.5–5.7 显示了 2019 年文档中我们将要搜索的 2019 年之后的文件中的数据表。

根据我们决定专注于招生、类型和发行商的决定,我们可以看到必要的数据仅存在于三个表中。我们可以验证这些数据表在 2018 年之后的年份在其各自的年鉴中存在。如果我们遇到了结构上的差异,我们就必须调查相同的信息是否在每个表中都存在,并确保它们的结构匹配,以便我们可以将它们跨多年合并成单个文件。

figurefigure

图 5.5 2019 年鉴的月度英国招生情况

figure

图 5.6 2019 年鉴按类型划分的发行和收入

figure

图 5.7 2019 年鉴按发行商的市场份额

5.4.2 从 PDF 中提取数据

现在我们知道了我们需要哪些数据表,我们有多种方法来提取它们。我们可以

  • 手动从 PDF 中复制有限的数据

  • 使用专门的 PDF 提取工具

  • 为我们首选的工具(例如,Python)查找 PDF 提取功能

表 5.1 显示了这些不同方法之间的权衡。

表 5.1 比较 PDF 提取技术
选项 优点 缺点

| 将数据从 PDF 手动复制到 Excel | • 对于少量数据来说很快 | • 无法自动化 • 无法扩展到更多数据 |

|

| 专用 PDF 提取工具,无论是基于 Web 还是桌面 | • 很可能准确 • 基于 Web 的工具无需安装 |

| • 可能不是免费的 • 上传文件到网络时的隐私问题 |

• 难以自动化

• 可能无法扩展到多个文件

|

| 在我们当前的工具中查找 PDF 功能 | • 允许自动化并扩展到多个文件 • 无需离开/更改我们首选的工具 |

| • 当前工具包可能没有这样的功能 • 如果文件很少且是一次性任务,手动提取数据可能更快。

|

无论我们决定哪种选项,我们仍然需要经历选择工具、实施/设置它以及使用它从 PDF 中提取数据的过程。让我们详细看看每个步骤,从在 AI 的帮助下选择正确的工具开始。

选择 PDF 提取方法

在这些选项中存在很多不确定性。处理我们只需要偶尔执行的具体任务是 AI 工具的完美用例。图 5.8 显示了 OpenAI GPT-3.5 模型对以下提示的部分回答:

从 PDF 中轻松提取数据表到机器可读格式的选项有哪些?建议的选项必须是免费、开源的,并且可以包括 Python 库。

figure

图 5.8 ChatGPT 建议的 PDF 数据提取选项列表

首先,重要的是要记住,由于这些工具正在快速发展,相同的提示将根据我们使用哪个 AI 工具以及何时使用它而给出不同的结果。我们可以看到,ChatGPT 根据要求推荐了 Python 库和非 Python 选项的混合,这为我们提供了大量的探索空间。然而,在调查其中一个非 Python 选项时,我们发现 TabbyPDF 的 GitHub 链接是错误的。在这种情况下,我们可以自己找到它,但这提醒我们 AI 工具有时会在建议中产生幻觉,甚至可能推荐不存在的工具。

让我们也使用 AI 工具帮助我们开始使用其中一个库。当被问及“在 Python 选项中,哪一个最容易设置且依赖项最少?”时,它的建议是从tabula-py开始,因为它有最少的依赖项。然而,它的依赖项是安装 Java 运行时环境,这是我们可能不愿意做的事情。它的下一个建议,pdfplumber库,没有这样的外部依赖项,但它的文档表明表格提取功能是一个“加分项”。另一个建议,camelot,专注于数据表提取,但根据 ChatGPT 的说法,由于它自己的外部依赖项,设置起来可能更困难。因此,我们不应将 AI 的回答视为完美;它应该是过程的开始,而不是结束。

在权衡我们的选择后,让我们先尝试使用pdfplumber,因为其他易于安装的 Python 库只是它的依赖项。如果它的表格读取功能不能给我们所需的结果,我们总是可以尝试另一个库,但我们将首先尝试保持简单。

使用pdfplumber可以通过您使用的任何包管理器来完成,无论是pipconda还是poetry。由于这些工具会自动安装依赖库,在这个步骤中我们几乎不需要做什么。然而,如果您选择使用不同的工具进行 PDF 提取,这个步骤可能更加复杂。

既然我们已经决定了一个方法,让我们在继续到实际提取步骤之前,总结一下到目前为止的过程。图 5.9 显示了到目前为止的过程。

图

图 5.9 选择 PDF 提取方法的步骤

现在我们将使用我们选择的工具从我们的 PDF 中提取结构化数据。

使用我们选择的工具从 PDF 中提取数据

在安装了 pdfplumber 之后,我们首先导入我们的库:

import numpy as np
import pandas as pd

import pdfplumber

from IPython.display import display

这可能是我们第一次使用 pdfplumber 库,因此为了开始,我们会阅读其文档以了解如何打开 PDF 并从中提取表格。由于我们将从多个文档的多个页面中提取多个表格,我们应该编写一个可重用的函数,该函数可以从单个 PDF 的多个页面中提取一个或多个表格。这个函数需要打开一个 PDF 文件,提取我们指定的页面中的所有表格,并将它们作为 pandas DataFrame 返回,以便进行分析。

让我们从提取代码开始。给定一个指定的路径和页码,以下代码将打开 PDF,遍历页面,并提取它所识别的所有表格:

pdf_path = "./files/2019 - BFI yearbook 2019 - 888.pdf"
page_num = 11

page_tables = []

with pdfplumber.open(pdf_path) as pdf:
    page = pdf.pages[page_num-1]
    page_tables = [t.extract() for t in page.find_tables()] #1

page_tables

1 page_tables 是从特殊的表格对象中提取出来的列表的列表(列表的列表!)。

到目前为止,page_tables 变量是一个包含列表的列表的列表。输出如图 5.10 所示。

图

图 5.10 pdfplumber 从单个 PDF 页面提取的表格

表格的每一行都是一个字符串列表,以列标题开头。这些行本身也是一个列表,代表一个单独的表格。使用 page.find_tables() 函数意味着我们最终得到一个这些表格的列表。因此,我们的数据结构是一个列表的列表的列表。幸运的是,pandas 让我们很容易将其转换为 DataFrame 的列表,如下面的代码片段所示:

table = page_tables[0]
pd.DataFrame(table[1:-1], columns=table[0])

这些代码片段可以扩展到跨多页工作,并且通过一些额外的打印语句和逻辑检查,整个函数在下面的代码片段中展示,其中一部分输出如图 5.11 所示:

def extract_tables(pdf_path, pages=None, print_tables=True):
  """
  Extract all tables found in a PDF.

  `pdf_path`: file path pointing to the PDF
  `pages`: the page number(s) to read
  `print_tables`: whether to also print out
  all the tables that are found (default: True)

  returns: a list of pandas DataFrames
  """

  if not pages:
      pages = []

  print(f"Reading {pdf_path}")

  tables = []

  with pdfplumber.open(pdf_path) as pdf:
      for page_num in pages:
          page = pdf.pages[page_num-1]

          page_tables = [t.extract() for t in page.find_tables()]

          df = [pd.DataFrame(table[1:-1], columns=table[0]) #1
↪ for table in page_tables]

          tables.extend(df)

  print(f"{len(tables)} tables found.")

  if len(tables) > 0:
    if print_tables:
        for index, df in enumerate(tables):
          print(f"\n##########################\n\tTable
↪ {index}\n##########################\n")
          display(df)

  return tables

tables = extract_tables("./files/2019 - BFI yearbook 2019 - 888.pdf",
↪ pages=[11,34,70])

1 在每种情况下,变量 table 现在是一个列表的列表,第一个列表包含列标题。

图

图 5.11 我们 extract_tables 函数的部分输出

存储我们函数输出的变量 tables 现在是一个 DataFrame 的列表,每个 DataFrame 代表从 PDF 中提取的一个表格。现在是时候将这个函数应用到我们的 PDF 上,提取我们需要的数据了。我们将通过 2018 年的数据来演示这个过程,但为了完整性,所有数据提取的代码都包含在补充代码列表中。

首先,我们确定感兴趣的页码,并使用我们的函数提取招生、类型和发行商数据:

tables_2018 = extract_tables("./files/2019 - BFI yearbook 2019 - 888.pdf",
↪ pages=[11,34,70])

完整的招生数据如图 5.12 所示。

图

图 5.12 从我们的 PDF 中提取的 2018 年招生数据

我们注意到这个数据的一个重要方面是,列本身并没有告诉我们这些数据属于哪一年。当我们需要将多年的招生数据合并时,这一点将非常重要,因此我们将添加一个年份列。我们也不需要 2017 年的数据或百分比变化,因此我们可以删除这些列。修改后的招生数据样本如图 5.13 所示:

admissions_2018 = (
    tables_2018[0]
    .iloc[:,[0, 2]]
)

admissions_2018.columns = ["Month", "Admissions (million)"]

admissions_2018.insert(0, "Year", 2018)

admissions_2018.head()

图

图 5.13 修改后的招生数据快照

接下来是类型,我们提取的 DataFrame 列表中的第二个表格包含我们需要的数据,如图 5.14 所示。

图

图 5.14 从 PDF 中提取的 2018 年类型分解

与招生数据不同,这种分解是按类型进行的,并涉及 2018 年整年的数据。我们仍然需要添加一个年份列来区分不同年份的类型,但我们需要记住这个数据集具有不同的粒度级别。同样,我们不需要所有列,并且我们需要清理列名以删除 \n 换行符。以下代码片段生成了修改后的类型数据,其快照如图 5.15 所示:

genres_2018 = (
    tables_2018[1]
    .drop(columns=[tables_2018[1].columns[2], tables_2018[1].columns[4]])
)

genres_2018.insert(0, "Year", 2018)

genres_2018.columns = ["Year", "Genre", "Number of releases",
↪ "Gross box office (£ million)", "Top performing title"]

genres_2018.head()

图

图 5.15 2018 年修改后的类型数据

最后,转向发行商,从 PDF 中直接提取的数据如图 5.16 所示。

图

图 5.16 从 PDF 中提取的原始发行商数据

在这个例子中,我们所有的列都将是有用的,我们需要再次添加年份列。我们还应该删除第 10 行,因为它上面所有行的总和,如果我们保留它,将会歪曲我们的计算。以下代码修改了数据以适应我们的需求,修改后的数据快照如图 5.17 所示:

distributors_2018 = (
    tables_2018[2]
    .drop(index=[10]) #1
)

distributors_2018.insert(0, "Year", 2018)

distributors_2018.columns = ["Year", "Distributor", "Market share",
↪ "Films on release", "Box office gross (£ million)"]

distributors_2018.head()

1 删除“前 10 名总计”行

图

图 5.17 2018 年修改后的发行商数据

注意:对后续年份重复此过程会显示一些差异。在 2019 年,同一页上有两个招生表格,因此我们需要明确选择正确的表格。此外,在 2022 年年鉴中,表格分布在多个 PDF 中。所有年份的表格提取过程都是相同的,但这些细微差异使得 PDF 数据提取变得复杂。

多年间的同类型数据集结构相同,因此我们不需要在合并之前进行任何额外的清理。以下代码展示了如何将年度招生数据集合并成一个单一的数据集:

admissions = pd.concat([admissions_2018, admissions_2019,
  admissions_2020, admissions_2021],
  ignore_index=True,
  axis=0)

为了确保我们有正确数量的数据,我们进行快速合理性检查,查看每年每月数据的行数。我们预计每年正好有 12 行,如图 5.18 所示的输出中已验证:

admissions["Year"].value_counts()

图

图 5.18 验证我们每年都有 12 行招生数据

最后,我们将合并后的招生数据写入其自己的文件:

admissions.to_csv("admissions.csv", index=False)

组合和导出类型和发行商数据的流程是相同的,我们最终得到三个我们准备分析文件。

5.4.3 分析从 PDF 中提取的数据

既然我们已经从我们的 PDF 中提取并合并了数据,我们可以开始探索它,以找到可用于我们白皮书的故事。我们将一次处理一个数据集,从招生数据开始。我们可以继续编写代码,这意味着我们已经有招生数据作为变量,但我选择明确地分离提取和分析过程。在配套资源中,您将找到将过程分为两个 Jupyter 笔记本。对于分析部分,我们将使用第一个笔记本中创建的数据启动一个新的 Jupyter 笔记本。

将提取与分析分离有多重好处:

  • 只要提取步骤产生分析步骤期望的输出,提取和分析就可以分别工作。

  • 通过不必每次分析更改时重新运行提取步骤,您可以节省时间。

  • 步骤可以更容易地维护,因为它们彼此之间逻辑上是解耦的。

通常,当您看到有机会通过将逻辑步骤彼此分离来创建更干净的解决方案时,您应该抓住这个机会。任何将来查看您的工作的人,包括您自己,都会为你在早期付出的额外努力感到感激。

使用自定义逻辑增强提取数据

我们首先读取招生数据并查看它。以下代码生成图 5.19 中的输出:

import pandas as pd
import matplotlib.pyplot as plt
import datetime
admissions = pd.read_csv("admissions.csv")
print(admissions.shape)
admissions.head()

图

图 5.19 招生数据的快照

由于它只是跨越几年的单一月度值,数据集很小。然而,它足以分为三个时期:COVID 前封锁期、COVID 封锁期和 COVID 解锁后。我们通过创建一个具有正确数据类型的日期列来实现这一点。以下代码的输出显示在图 5.20 中:

COVID_START_DATE = datetime.datetime(2020, 3, 1)     #1
LOCKDOWN_END_DATE = datetime.datetime(2021, 7, 1)

admissions["date"] = (
    "1 " +      #2
    admissions["Month"] +
    " " +
    admissions["Year"].astype(str)
)

admissions["date"] = pd.to_datetime(admissions["date"], format="%d %B %Y")
admissions.head()

1 我们定义变量来标记 COVID 时期的截止点。

2 我们的数据没有日期,所以我们随意将日期设置为每月的第一天。

图

图 5.20 验证我们新添加的日期列是否符合预期

接下来,我们定义三个时期的截止点并将它们应用于数据。我们还使用 pandas 中的 Categorical 数据类型来确保在排序时观察到正确的顺序;否则,这些时期将按字母顺序排序。然后,我们验证这个新列的分布是否符合我们的预期,并且我们没有遗漏任何数据。以下代码的输出显示在图 5.21 中:

admissions.loc[admissions["date"] < COVID_START_DATE, "covid_period"]
↪ = "pre-COVID"
admissions.loc[admissions["date"].between(COVID_START_DATE,
↪ LOCKDOWN_END_DATE, "left"), "covid_period"] = "during COVID"
admissions.loc[admissions["date"] >= LOCKDOWN_END_DATE, "covid_period"]
↪ = "post-lockdowns"

admissions["covid_period"] = (
    pd.Categorical(
        admissions["covid_period"],
        categories=["pre-COVID", "during COVID", "post-lockdowns"],
        ordered=True
    )
)

admissions["covid_period"].value_counts(dropna=False)

图

图 5.21 不同 COVID 时期的行数

现在我们可以使用以下代码绘制月度招生数据,并用不同颜色和线条样式标记每个 COVID 时期,如图 5.22 所示:

fig, axis = plt.subplots(figsize=(10, 6))

linestyles = ["solid", "dotted", "dashed"]

for idx, covid_period in 
↪ enumerate(admissions["covid_period"].value_counts().index):
    (
        admissions
        .query(f"covid_period=='{covid_period}'")
        .set_index("date")
        ["Admissions (million)"]
        .plot(ax=axis, label=covid_period, linestyle=linestyles[idx])
    )

axis.set(
    title="Monthly cinema admissions over time",
    ylabel="Admissions (millions)"
)

axis.legend()

plt.show()

图

图 5.22 多个 COVID 时期的招生情况

初看之下,由于封锁期间缺失了几个月份,在最后一次封锁解除后,似乎入院人数已经开始恢复到疫情前的水平。如果我们有更多的后封锁数据,我们还可以检查季节性模式是否与疫情前相似。

如果我们要查看三个时期的平均每月入院人数,我们可以进一步调查这一点。以下代码执行此操作,并产生图 5.23 所示的输出:

fig, axis = plt.subplots()

admissions_by_period = (
    admissions
    .groupby("covid_period")
    ["Admissions (million)"]
    .agg(["mean", "median"])
)

for i, metric in enumerate(admissions_by_period.columns):
    hatch = "/" if i == 0 else "\\\\"                            #1
    color = "C0" if i == 0 else "C1"     #2
    admissions_by_period[metric].plot(kind="bar", ax=axis, position=i,
                                      hatch=hatch, label=metric,
                                      width=0.2, color=color)

axis.set(
    title="Average monthly cinema admissions during COVID periods",
    ylabel="Admissions (millions)",
    xlabel="Period",
    xticklabels = admissions_by_period.index
)

axis.legend()

plt.show()

1 不同指标使用不同阴影模式

2 不同指标使用不同颜色

figure

图 5.23 按 COVID 时期划分的平均入院人数

观察均值和中位数的原因是为了调查数据是否在任一方向上存在偏斜。也就是说,COVID 前或后封锁的月份是否倾向于在任一方向上有异常值?通过查看按时期划分的每月入院人数的直方图可以进一步调查这一点。以下代码生成了图 5.24 所示的直方图:

fig, axes = plt.subplots(1, 2, figsize=(10, 6), sharey=True)

(
    admissions
    .loc[admissions["covid_period"] == "pre-COVID", "Admissions (million)"]
    .hist(bins=10, ax=axes[0])
)

axes[0].set(
    title="Distribution of monthly admissions pre-COVID",
    xlabel="Admissions (million)",
    ylabel="Frequency"
)

(
    admissions
    .loc[admissions["covid_period"] == "post-lockdowns",
↪ "Admissions (million)"]
    .hist(ax=axes[1])
)

axes[1].set(
    title="Distribution of monthly admissions post-lockdowns",
    xlabel="Admissions (million)"
)

plt.show()

figure

图 5.24 COVID 前和后封锁的入院人数直方图

后封锁的数据并不多,但我们可以观察到有更多月份的入院人数较少,这是根据缓慢的恢复所预期的。COVID 前,我们预计每月大约有 1500 万至 1700 万的入院人数,正负方向上都有一些异常值。目前,关于后封锁的入院习惯没有太多可以讲述的故事,除了注意到到 2021 年底,入院人数似乎又开始趋向于 COVID 前的水平。

使用来自多个来源的数据研究趋势随时间的变化

现在,让我们看看在疫情前后流行的品类。首先,让我们读取并检查我们的数据。图 5.25 显示了以下代码产生的行快照:

genres = pd.read_csv("genres.csv")
print(genres.shape)
genres.head()

figure

图 5.25 品类数据集的行快照

有两点需要注意。首先,由于只是记录了几年内的少量品类,因此这个数据集很小。其次,这个数据集是按年度级别,而不是按月度级别,这改变了我们对 COVID 时期的定义。具体来说,我们必须接受 2020 年最初几个月的数据将被分配为“在 COVID 期间”,即使它们发生在第一次封锁之前,并且我们需要决定如何处理 2021 年的数据。2021 年仍然有封锁,但下半年的数据是关于后封锁趋势的有用数据,我们不想将其丢弃。我们将倾向于将 2021 年视为“后封锁”,并将 2020 年视为唯一的“在 COVID 期间”的年份。以下代码执行此操作,并产生图 5.26 所示的输出:

genres.loc[genres["Year"] < 2020, "covid_period"] = "pre-COVID"
genres.loc[genres["Year"] == 2020, "covid_period"] = "during COVID"
genres.loc[genres["Year"] > 2020, "covid_period"] = "post-lockdowns"

genres["covid_period"].value_counts(dropna=False)

figure

图 5.26 品类数据集中按 COVID 时期分布的行

看数据时,我们可能会注意到票房总收入列有非数值值,即<0.1,表示总收入低于 10 万英镑的类型。例如,为了按类型计算收入,我们需要这一列是数值的。我们可以将这个值转换为零,但这将是误导性的,并且这个值与数据中存在的 0.1 不同。为了表示低收入,我们可以添加一个占位符值,比如 0.05:

genres.loc[genres["Gross box office (£ million)"] == "<0.1",
↪ "Gross box office (£ million)"] = 0.05
genres["Gross box office (£ million)"] =
↪ genres["Gross box office (£ million)"].astype(float)

图

图 5.27 按类型划分的总收入

现在,我们可以查看数据集中按类型划分的总收入。以下代码的输出显示在图 5.27 中:

fig, axis = plt.subplots()

(
    genres
    .groupby("Genre")
    ["Gross box office (£ million)"]
    .sum()
    .sort_values()
    .plot
    .barh(ax=axis)
)

axis.set(
    title="Total revenue (£ millions) by genre",
    xlabel="Gross revenue (£ million)"
)

plt.show()

看起来人们最喜欢动作和动画电影。我们真正想看到的是这种按年份相同的分布。我们也可以按 COVID 时期来看,但让我们先看看更细致的图景。以下代码实现了这一点,并在图 5.28 中产生了输出:

years = genres["Year"].unique()

fig, axes = plt.subplots(1, len(years),
↪ figsize=(3*len(years),8), sharex=True)

for idx, year in enumerate(years):
    (
        genres[genres["Year"] == year]
        .groupby("Genre")
        ["Gross box office (£ million)"]
        .sum()
        .sort_values()
        .plot
        .barh(ax=axes[idx])
    )

    axes[idx].set(
        title=f"Revenue by genre ({year})"
    )

plt.tight_layout()
plt.show()

图

图 5.28 多年按类型划分的收入分解

这给出了一个非常清晰的图景。动作电影无论在哪个年份都是最受欢迎的。喜剧电影在 COVID 之前的表现一直不太好,但在 2021 年其受欢迎程度有所上升。以下是关于这一结果的几种理论:

  • 动画电影需要多年的努力,如果动画师在 COVID 期间没有在任何时候工作,那么这就会推迟 2021 年动画电影的上映。

  • 相比之下,喜剧电影可能制作成本更低,但这是一个需要领域专家回答的问题。

  • 在解封后,人们可能更喜欢轻松愉快的慰藉。

让我们先测试这个第一个假设。如果我们的理论成立,我们应该看到 2021 年动画类型的上映数量更少。图 5.29 显示了这项调查的输出:

(
    genres[genres["Genre"] == "Animation"]
    .groupby("Year")
    ["Number of releases"]
    .sum()
)

图

图 5.29 随时间动画电影发布数量

2021 年上映的动画电影数量几乎与 2019 年一样多,因此我们的理论无法解释为什么该类型的收入下降了。从图 5.28 中还可以看到 2020 年战争电影的流行:

genres[(genres["Genre"] == "War") & (genres["Year"] == 2020)]

深入研究后,我们发现这个类别中表现最好的电影是电影1917,该电影于 2020 年 1 月上映。尽管这部电影的收入使其在 2020 年的排名中占据高位,但它是在第一次封锁之前上映的,因此这并不能告诉我们任何关于人们在大流行期间喜欢战争电影的信息。这当然不能作为证据表明人们在大流行期间喜欢战争电影。

总结来说,尽管动作电影无疑是最受欢迎的类型,但喜剧电影在解封后收入有所增长。这无疑是一个值得将领域专家引入以了解更多信息的发现。

解决跨数据源的不同实体名称

我们最后一个问题与发行商有关。哪些公司在封锁期间为他们的电影赚取了最多的收入?让我们看看我们可用的数据在图 5.30 中:

distributors = pd.read_csv("distributors.csv")
print(distributors.shape)
distributors.head()

figure

图 5.30 发行商数据集快照

对于每个发行商,我们都有他们的收入市场份额、每年发布的电影数量和总票房收入。让我们再次分配我们的 COVID 时期。图 5.31 显示了每个 COVID 时期的数据行数:

distributors.loc[distributors["Year"] < 2020, "covid_period"] = "pre-COVID"
distributors.loc[distributors["Year"] == 2020, "covid_period"]
↪ = "during COVID"
distributors.loc[distributors["Year"] > 2020, "covid_period"]
↪ = "post-lockdowns"

distributors["covid_period"].value_counts(dropna=False)

figure

图 5.31 分销商数据中每个 COVID 时期的行数

我们还应该验证市场份额列每年是否加起来是 100%。图 5.32 显示了这是否是情况:

distributors.groupby("Year")["Market share"].sum()

figure

图 5.32 每年的总市场份额

统计年鉴明确提到了舍入误差,这可能是图 5.32 中数字的原因。现在让我们看看我们数据的每年按发行商的市场份额。使用pandas的最简单方法是为每个不同的发行商创建一个交叉表,其中每列代表一个不同的发行商,每行代表一个不同的年份。这样,当我们调用plot函数时,我们可以看到每个发行商随时间的变化。让我们看看这种数据重塑会做什么。以下代码生成了图 5.33 所示的交叉表:

(
    distributors
    .groupby(["Year", "Distributor"])
    ["Market share"]
    .sum()
    .unstack()
)

figure

图 5.33 按发行商和年份汇总的交叉表快照

存在许多数据问题变得明显。我们需要合并 20 世纪福克斯的两个单独值,将“其他”类别合并成一个单一名称,以便它们随着时间的推移获得单行,并调查名称中包含“娱乐”的各个发行商。经过调查,发现娱乐一公司和 eOne Films,这两者都出现在我们的数据中,实际上是同一实体(www.entertainmentone.com/about-eone/)). 娱乐电影发行商是 Entertainment One 的一个合法独立实体,但我们没有证据表明仅标记为“娱乐”的发行商是否应该合并到其他任何类别中。我们将将其保留独立。在这些更正之后,我们可以查看每个发行商在我们的数据中代表了多少年。这一结果在图 5.34 中显示:

distributors["Distributor"] = (
    distributors["Distributor"].replace({
        "20th Century Fox*": "20th Century Fox",
        "eOne Films": "Entertainment One"
    })
)

distributors.loc[distributors["Distributor"].str.startswith("Other"),
↪ "Distributor"] = "Other"

distributors["Distributor"].value_counts()

figure

图 5.34 每个发行商在我们的数据中出现的年数

我们还可以使用这个结果来决定是否绘制所有发行商。我们特别关注随时间的变化,因此我们应该只保留出现在我们数据所有年份中的发行商。这可能会省略在疫情期间或因疫情而倒闭的发行商。这些可以进一步单独分析:

distributors_to_keep = (
    distributors["Distributor"]
    .value_counts()
    .loc[lambda x: x == distributors["Year"].nunique()]
    .index
)

我们可以使用这些发行商来重新创建我们的交叉表,并查看市场份额在年份间的比较:

distributors_pivot = (
  distributors
  .query("Distributor in @distributors_to_keep")
  .assign(Year=distributors["Year"]
          .apply(lambda x: datetime.datetime(x, 1, 1))     #1
         )
  .groupby(["Year", "Distributor"])
  ["Market share"]
  .sum()
  .unstack()      #2
)

1 将每年转换为 1 月 1 日以使其成为日期类型

2 每年一行,每家发行商一列

从视觉上看,如果年份跨越列,交叉表更容易阅读,所以让我们转置数据,并检查图 5.35 中显示的输出:

distributors_pivot.transpose()

figure

图 5.35 发行商市场份额(%)随时间变化

从这个交叉表中,我们可以得出以下结论:

  • 华特迪士尼在 2019 年完成了对 20 世纪福克斯的收购,但这并没有导致市场份额在 2021 年有大幅增长。

  • 索尼在解封后实现了市场份额的最大增长,将市场份额从 2018 年翻倍,但环球也从 COVID 之前的水平增加了市场份额。

  • 除了索尼和环球之外,市场份额较大的发行商在 2021 年大致回到了 COVID 之前的市场份额水平。一些较小的发行商,如 StudioCanal,未能达到 COVID 之前的水平。

对这些结果的初步推测可能是,较大的发行商是那些拥有资源从全球大流行中恢复过来的,而较小的发行商可能会遇到更大的困难。

在总结我们的发现之前,让我们回顾整个过程,包括我们的最新步骤可能发生分歧的地方。图 5.36 显示了整个过程。

figure

图 5.36 示例解决方案中采取的最终步骤

现在我们来回顾我们的发现,并总结我们对利益相关者的建议。

5.4.4 项目结论和建议

我们的结果对白皮书意味着什么?

  • 英国层面的招生数据并没有讲述一个非常有趣的故事,除了招生看起来像是达到了 COVID 之前的水平。额外一年的数据将有助于阐明解封后的趋势,同样,从更细致的层面,如地区或电影类型来看招生情况,也会有所帮助。

  • 解封后,类型分布有所不同,确实有足够的不同来进一步调查这个角度。

  • 发行商遵循一般市场趋势,大型实体比小型实体更容易从 COVID 中恢复过来。

我们分析的限制主要在于数据不足。我们没有很多解封后的数据可以与过去趋势进行比较,因此我们的结论只能是试探性的。我们的数据集中在英国,因此它不能提供一个全球的视角。然而,我们可以假设我们工作的研究公司会有更细致的数据,以便我们进一步调查我们的发现。我们的结论中有足够的论据可以与我们的领域专家进行对话,但第一轮分析并不表明我们有足够的论据来支持一份白皮书。

这种分析的一个好处,无论它是否导致发表论文,就是我们编写了一个数据管道,用于从 BFI 发布的统计数据中提取数据。以这种格式拥有这些数据将为未来的项目带来价值。有时,以这种方式清理数据本身就是一项有价值的贡献。

活动:使用这些数据进一步的项目想法

本章中包含的 PDF 文件是关于电影行业信息的宝库。这个项目专注于大流行的影响,但还有许多其他角度可以探索,以及许多可以回答的研究问题。以下是一些帮助你开始的想法:

  • 人们的类型偏好是如何随时间演变的?

  • 成功的独立电影有什么样的模式吗?

  • 不同国家的观众人数是如何随时间变化的?电影观众是否在各地以相同的方式改变?

5.5 关于探索新颖数据源的总结性思考

从本章中我们可以得到两个启示。一个是你会遇到需要学习特定、狭窄的技能来从异常格式中提取数据的情况。这是一个了解你现有工具中更神秘部分的好机会,例如其 PDF 提取功能。AI 工具可以加速这一学习过程,本章的项目就是一个深度不是必需的例子。要成功完成本章的项目,你不需要成为 PDF 提取或光学字符识别的专家,即图像转换为机器可读文本形式的过程。你只需要找到相关的库和代码片段,从 PDF 中提取表格数据。这是一个学习足够以保持对最终结果专注而无需更多的例子。

其次,更广泛的启示是数据以许多形式存在。知道我们的工具使我们能够从异常、无结构的数据源中提取数据,这将扩大我们用于分析的可用数据潜力。它使我们能够为问题生成更多创造性的解决方案,这将帮助我们通过我们的工作增加更多价值。

5.5.1 探索任何项目的不寻常数据源所需的技能

在本章中,我们探索了一种新的数据来源,即 PDF 文件。对于不寻常的数据源,以及更广泛地探索新的数据源,这些技能可以用于任何问题,包括

  • 在非结构化文件中找到相关数据,例如 PDF

  • 识别现有工具从新颖的数据源中提取数据

  • 使用 AI 工具(如 ChatGPT)了解特定数据格式的特定工具

  • 使用新工具从非结构化源提取数据到结构化格式(例如,从 PDF 到 CSV 文件的数据)

  • 使用自定义逻辑(例如,COVID 封锁前后的时期)增强提取的数据

  • 通过从多个来源随时间提取相似数据来调查趋势(例如,跨多年的相同年度报告)

  • 解决多个类似数据源之间的差异(例如,随着时间的推移,制作公司的名称发生变化,但与同一实体相关)

摘要

  • 识别新颖和无结构的数据源是优秀分析师的核心技能。

  • 在分析中考虑新的数据源可能会引入非表格或无结构数据,需要付出努力来清理:在包括它们时应考虑的时间。

  • 将数据中蕴含的故事讲述出来,而不是我们利益相关者在问题陈述中要求的那一个,这对于避免误导自己至关重要。

  • 专注于要解决的问题而不是数据,会增加你考虑的任何额外数据相关的可能性。**

第六章:6 分类数据

本章涵盖

  • 确定处理分类数据的最佳方法

  • 在处理分类数据时如何避免常见错误

  • 使用正确的方法分析分类数据以调查模式和关联

有时,你可能会遇到非数值型数据,你的典型分析方法不适用。相反,这种数据代表具有有限选项集合的组或类别。客户位置、部门和人口统计信息是此类离散值的典型来源。我们称这种数据为分类数据,它在大多数数据集中很常见。

适用于数值或连续数据的方 法不适用于分类数据。了解正确处理分类数据的方法将扩展你的工具箱,并确保当你的数据主要是分类数据时,你使用正确的工具来完成工作。在本章中,我们将在深入研究项目之前,回顾处理分类数据的常用工具。

6.1 处理分类数据

任何分析中的第一个任务之一是理解你的数据。在表格数据集中,每一列的数据将属于两种类型之一:连续或分类。这种区分不适用于非结构化数据,如音频或视频文件,但大多数分析师主要处理表格数据。无论这些列中的值代表什么,它们要么是连续尺度上的,要么是离散集合的一部分。有时,如果条目是数值型的,甚至可能不明显是否某一列是分类的。让我们看看一个示例数据集。

图 6.1 显示了机器学习社区用于教学目的的广泛使用数据集的快照。原始数据由 Jánosi 等人(1988 年)创建,可在mng.bz/vKo7找到。这是一个不同患者测量值的医学数据集,也显示了患者是否患有冠心病。相关数据问题是用测量值来预测患者是否患有冠心病。

figure

图 6.1 UCI 心脏病数据集的预览

前两列,年龄性别,分别容易识别为连续和分类。尽管性别是数值型,但我们将其视为与文本值(例如,“男性”或“女性”)相同。例如,计算此列的数值平均值与计算我们患者的平均年龄并不相同。我们称这种类型的分类列为名义,因为类别之间没有自然的顺序。其他例子包括颜色或政党。

分类数据也可以是有序的,其中类别确实有自然顺序,但不是数值尺度。例子包括尺寸(例如,小、中、大)或调查响应(例如,在“强烈不同意”和“强烈同意”之间的值)。为了完整性,表 6.1 显示了我们可以遇到的不同类型数据的分解。

表 6.1 不同数据类型的概述
姓名 分类或连续? 属性 示例
名义 分类 数据具有离散值,类别没有顺序。 颜色、政党
序数 分类 数据具有离散值,类别之间有自然顺序,但类别之间的间隔不均匀。 T 恤尺码(S/M/L)、调查响应(好/坏)
间隔 连续 值间隔均匀,但零不是测量值的缺失。 温度
比率 连续 值间隔均匀,零表示测量值的缺失。 身高、体重

备注:在这些定义中,“比率”一词不应与两个测量值相互比较的意义混淆,比如新客户与回头客的比率。在这种情况下,“比率”是连续变量的技术术语,它从零开始,其中零表示该数量的缺失。你可以有负温度,但不能有负身高,这是“间隔”和“比率”量之间的区别。

回到心脏病例子的讨论。关于年龄性别之外的列,又是怎样的情况呢?如果我们只检查导入数据时的数据类型,我们会观察到所有列都是数值型的。图 6.2 展示了验证这一点的 Python 输出示例。

figure

图 6.2 心脏病数据中的数据类型

根据目前的信息,我们可能会得出结论,因为我们的列是数值型的,所以所有数据都必须是连续的。然而,经过更仔细的检查,我们发现我们的一些列只取了少数几个离散值。例如,图 6.3 展示了slope列值的分布情况。

figure

图 6.3 心脏病数据集中slope列值的分布

尽管这是一个整数列,但只有三个离散值。这些值对应于患者的与运动相关的测试是在上升坡度、下降坡度还是完全没有进行。因此,这个列是分类的,具体来说是名义的。

这可能看起来像是一个微不足道的发现,但如果我们将这个列当作连续的来处理,我们可能会用它进行错误的计算,比如取平均值。我们可能会得到平均坡度值为 0.6,但这没有意义。它并不代表介于 0(上升)、1(平坦)之间的中间值,因为编号是任意的。我们分配给每个类别的数字的改变会改变这个结果及其解释。

真实业务案例:通过分类数据增强分析

参加拍卖的二手车会有各种各样的数据被输入,其中很多是分类数据。变速器类型、燃料类型、制造商和型号都是分类值,对于理解二手车市场至关重要。我建立的用于预测二手车价格或其销售可能性的模型都极大地依赖于这些分类值。

我们的团队也定期被要求查看汽车的颜色是否对其价值有任何影响。这不仅涉及到操作一个分类的color列,还涉及到从人们可以输入任何颜色的自由文本字段中提取这些类别!

那么,我们如何处理分类数据呢?让我们回顾一下连续数据与分类数据方法之间的一些差异。

6.1.1 处理分类数据的方法

让我们看看分析连续和分类数据的一些关键方法。当我们处理连续数据时,我们特别感兴趣的是

  • 我们值的范围

  • 我们值的分布

  • 异常值的存在

  • 连续值之间的关联

为了探索这些特性,我们可用的工具是

  • 概率统计(最小值、最大值、平均值、中位数等)

  • 直方图和箱线图来调查范围和分布

  • 散点图和相关度量来调查连续变量之间的关系

当涉及到分类数据时,并不是所有这些方法都适用。让我们回顾一下心脏病数据集,以一个例子来说明将分类值视为连续值会导致我们误入歧途。

我们可能会犯的一个错误是将分类的slope变量在回归模型中当作连续变量来使用,也就是说,在一个我们试图根据测试时的设备斜率来预测某人患心脏病风险的场景中。我们可能会得到一个结果,其解释为“斜率每增加一个单位,我们患者患心脏病的可能性增加 7%。”这种解释对于年龄这样的连续度量是有效的,因为某人年龄每增加一年,患心脏病的风险就会增加。然而,“单位增加”的斜率值意味着什么呢?由于编号是任意的,这实际上并不代表任何意义,整个问题被错误地表述了。

对于回归的slope列的正确处理方法是将它转换为二元指示变量,每一列代表分类列中可能的离散值之一。在我们的斜率示例中,我们会创建代表每个患者的斜率值是 0、1 还是 2 的列,因此我们会创建三个列,每个可能的斜率值一个列。这些列的值将是 0 或 1,具体取决于斜率值是 0、1 还是 2。因此,这些二元列将是互斥的,因为slope列只能有一个值。这也被称为独热编码,在我们的场景中,这种编码的结果如图 6.4 所示,与原始的slope列并排展示。

备注:你可能也见过“虚拟变量”这个术语来描述这种格式。技术上,虚拟变量是省略了一列的独热编码,但概念是相同的。

figure

图 6.4 原始的slope列和通过应用独热编码创建的三个新列

从这个格式中,我们仍然可以推断出每个患者的slope列的值,但现在,这些列也可以作为回归模型的输入。这只是有专门针对分类数据的方法的一个例子。一般来说,对于分类数据,我们感兴趣的是

  • 我们值的频率分布

  • 我们分类值与其他值之间的关联,包括连续和分类值

一些专门针对这些属性处理分类数据的方法是

  • 分组和聚合

  • 条形图来可视化值的频率分布

  • 交叉表和交叉表来比较两个分类列的共现情况

  • 通过分类列着色来显示连续变量在不同类别中的分布的直方图和箱线图

有许多其他方法可以研究连续和分类数据,其中一些我们将在本章中更详细地探讨。我们将要处理的一个常见的分类值来源是调查响应数据。

6.1.2 处理调查数据

问卷调查数据有其特定的考虑因素,而不仅仅是它是分类列的常见来源。在我们进行分析时,我们需要考虑以下因素:

  • 问卷调查数据是自我报告的,这意味着它会有特殊的偏见。人们可能会选择出于个人原因省略答案或提供不真实的答案。回答调查的参与者可能是那些愿意回答调查的人,这再次意味着样本是有偏见的。

  • 数据可能因为不同于其他数据集的原因而缺失。缺失数据可能表明不愿意回答特定问题,用户在调查中途退出,或者基于其他问题的答案对用户不可用的问题(例如,如果他们回答了是,则需要进一步阐述)。

  • 许多答案将采用李克特量表,参与者从“强烈不同意”到“强烈同意”中选择五个答案中的一个。这意味着我们将处理有序值——具有自然顺序但不是一致数值尺度的类别。

我们得到的启示是,我们不应低估了解我们数据的重要性,而了解我们每一列的正确类型以及数据本身的来源是其中的关键方面。现在让我们在本章的项目中检验这一点。

6.2 项目 5:分析调查以了解开发者对 AI 工具的态度

现在让我们看看我们的项目,我们将分析调查结果以了解软件开发者如何使用 AI 工具。大部分数据是分类的,因为数据来源是一个包含大量多项选择题的调查,这些题目的结果产生了分类值。我们将查看问题陈述、数据字典、我们应追求的输出以及我们的工具需要解决此问题的能力。然后我们将使用以结果为导向的框架进行详细规划,然后再深入研究一个示例解决方案。

数据可在davidasboth.com/book-code处获取,你可以找到尝试项目所需的文件,以及以 Jupyter 笔记本形式的示例解决方案。

6.2.1 问题陈述

在这个场景中,你作为 AI Dev Elite 的分析师,一个专注于为编写代码的人创建生成式 AI 工具的 AI 创业公司。他们正努力聚焦产品理念,因此他们要求你研究编码者目前如何使用生成式 AI 工具。他们希望识别人们的痛点并找到可以利用的市场空白。

他们有两个假设希望你来测试:

  • 新手和经验丰富的编码者使用这些工具的方式不同。

  • 人们对于当前 AI 工具的有用性和可信度的看法取决于他们的经验、工作角色以及他们具体使用工具的目的。

尽管他们很想直接访问人们输入到这些工具中的查询,但他们已确定 Stack Overflow 开发者调查是本项目的第一轮迭代中一个很好的信息来源。在这项调查中,开发者透露了关于他们的工作、当前工具以及他们对生成式 AI 工具的使用和态度的详细信息。他们希望我们从这些调查结果中测试他们的假设。

备注:感谢 Stack Overflow 在此处提供他们的开发者调查结果:insights.stackoverflow.com/survey,根据开放数据库许可(ODbL)。

6.2.2 数据字典

该数据集的数据字典分为两部分,均包含在补充材料中:

  • 有一个按问题分解的列表,survey_results_schema.csv记录了哪些列包含答案。一些问题的答案分布在多个列中。

  • 此外,还有调查本身的 PDF 副本。有了这个,您可以直接观察数据生成过程,这是很少见的。

我建议首先查看调查,因为这会将数据字典和数据本身中的列置于上下文中。直接查看调查问题将有助于确定分析中感兴趣的问题。然后,您可以使用survey_results_schema.csv文件在答案数据中找到相关的列名。图 6.5 显示了此查找文件的摘录。

figure

图 6.5 使用作数据字典的映射文档摘录

如图 6.5 所示,关于某人专业编码多少年的问题在答案数据中引用为YearsCodePro。这就是我们在示例解决方案中识别所有相关分析列的方法。

6.2.3 预期结果

由于我们的利益相关者有特定的假设,我们的分析应专注于这些假设。我们需要对数据进行一些一般性的探索,以识别缺失值等,但我们的重点是请求的具体内容。我们最小可行的答案应该是支持或反驳这些假设的证据。因此,

  • 我们的结论应包括是否在不同经验水平上使用 AI 存在差异。

  • 我们应该传达影响人们对 AI 工具看法和信任度的因素,这些因素得到了调查数据的支持。

6.2.4 必需工具

与大多数章节一样,只要您的工具可以读取、探索和可视化数据,就可以完成这个项目。在示例解决方案中,我使用 Python、pandas库进行数据探索,以及matplotlibseaborn进行可视化。在调查数据中的关联性时,我还介绍了一些来自scipy库的统计函数。因此,这个项目的清单是,您的工具可以

  • 从 CSV 或 Excel 文件中加载多个数据集

  • 合并两个或多个数据集

  • 执行基本的数据操作任务,例如排序、分组和重塑数据

  • 创建数据可视化

  • 生成统计分析,特别是分类数据的统计分析,但这不是必需的

6.3 将结果驱动方法应用于分析开发者调查

现在我们来看看我们如何保持我们的结果在焦点上,并制定一个以结果为导向的行动计划。我们可以遵循以结果为导向的过程的步骤,在考虑利益相关者的请求和假设的同时探索数据。

figure

首先,我们需要了解我们的利益相关者希望从这个分析中得到什么。他们的初步目标是找到证据来支持或反驳他们的初始理论,但他们的最终目标是找到一个可以利用 AI 产品的市场空白。在分析数据时,我们可以牢记这两个目标,以确保我们的结果有价值。

figure

从我们的利益相关者的请求中,我们知道需要创建两种类型的输出:

  • 不同经验水平开发者之间使用 AI 工具差异的证据

  • 分析决定某人评估 AI 工具有用性和可信度时得分的因素

像往常一样,以结果为导向的焦点意味着我们可以忽略分析可能采取的某些路径,如果这些路径不能帮助我们找到对特定问题的答案。在实践中,这意味着忽略调查中与经验、AI 工具的有用性或可信度或可能与之相关联的因素无关的问题。

figure

在这个项目中,数据源的选择已经为我们完成。这种情况并不少见,而且一如既往,我们正在在可用数据的限制内工作。

figure

在这个阶段,有一个问题需要解决,那就是我们是否想查看 2023 年之前(目前最新版本)的调查。我们有多个调查可供选择,因此我们可以选择包括过去几年的数据。然而,反对这样做有两个论点。首先,我们希望有一个最小可行答案,这意味着一开始就使用较少的数据。其次,AI 工具的真正兴起是在 2023 年,所以早期的数据可能不会给我们提供更多有助于我们特定分析的信息。

figure

在深入分析问题之前思考过,这是我们制定分析行动计划的部分。我们大致想要采取以下步骤:

  • 阅读调查以确定确切的问题。

  • 检查数据字典和我们的数据,看看问题如何与数据中的列相关。

  • 通过查看通常的属性,如缺失数据、异常值和类似情况来探索数据。

  • 确定与我们的研究问题相关的问卷问题和列。

  • 分析我们的变量与我们所感兴趣的 AI 相关结果之间的关系。

  • 根据我们的利益相关者的假设总结我们的发现。

提示:这是一个可能有用统计测试的项目,因为我们有特定的假设要调查。无论正确与否,利益相关者喜欢询问某事是否具有统计学意义,这是为他们提供实际答案的机会。

figure

在这一步,我们希望展示证据来支持或反驳我们的利益相关者的假设,这些假设如下:

  • 在不同经验水平上使用 AI 存在差异。

  • 影响一个人对 AI 工具的看法和信任度的因素包括他们的经验水平、工作角色以及他们具体使用工具的目的。

我们特别希望提供最完整的答案,同时仍然能够得到我们数据的支持。我们不希望我们的利益相关者根据不是基于稳健发现的建议采取行动,所以除非我们对发现的关联有信心,否则我们应该使用适当的语言。像“数据表明”这样的短语通常比“证明”或“显示”这样的词更受欢迎。

figure

一旦我们提出了我们的发现,与我们的利益相关者的讨论很可能会导致进一步探索数据的问题,以及有助于我们分析的其他数据来源。我们的目标是尽快达到这一点,而不是更晚,因此我们应该在我们的第一次迭代中只包含最小限度的复杂性。

6.4 示例解决方案:开发者如何使用 AI?

现在我们来逐步分析这个问题的解决方案。一如既往,如果你在阅读这一节之前尝试了项目,你会发现它更有价值,因为你可以将两种解决方案进行比较,当然,通常的警告是,我们的解决方案可能仅仅是因为我们做出的选择而有所不同。

当务之急是决定哪些列代表我们感兴趣的提问,因此,我们将关注哪些。接下来,我们将在查看影响开发者对 AI 态度的因素之前,对数据进行一个高层次、一般性的探索。

6.4.1 探索分类数据

通常,在一个分析项目中,第一步是查看可用的数据。然而,我们有一个难得的机会通过查看生成我们数据集的调查本身来观察数据生成过程。在滚动浏览它时,以下是初步观察:

  • 大多数问题不是强制性的,这意味着我们可以预期会有大量缺失数据。

  • 关于年龄、编码年数和职位的问题看起来立即相关。

  • 每个与特定工具或技术相关的问题都包括标记它们为“目前正在使用”和“希望明年使用”的选项,AI 相关的问题还包括“感兴趣使用”和“不感兴趣使用”,以及仅仅是“目前正在使用”。拥有所有这些选项意味着我们可以交叉引用答案以找到更深层次的关系。

  • 参与者可以通过使用李克特量表来标记他们认为 AI 工具有多有利和值得信赖,这是允许给出积极、中立和负面答案的调查问题的技术名称,并且代表有序数据。

  • README 文件中提到“自由回答提交已被移除”,这很遗憾,因为我们将会缺少对调查问题“请描述一下,如果一年后由于人工智能的进步,你的工作流程会有何不同(如果有不同的话)”的答案。此外,我们也不会看到有“其他”选项的问题的答案,参与者可以在其中进行详细说明。

当我们对数据有疑问时,我们无疑会重新审视调查本身,但就目前而言,我们对数据的了解已经足够开始我们的探索。让我们看看调查问题与我们的数据集中的行是如何关联的:

import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt

survey = pd.read_csv("./data/survey_results_public.csv.gz")
print(survey.shape)
survey.head()

这段代码的输出告诉我们,我们正在处理 89,000 份调查回答,其中一部分在图 6.6 中展示。

图

图 6.6 原始调查数据的快照

从这个快照中,我们已能看出

  • 如预期的那样,会有缺失值。

  • 大多数答案,甚至年龄,都是以文本形式表示的分类数据。

  • 多选题的答案存储为分号分隔的字符串。这种类型的数据可能有多种存储方式,因此确定我们面对的是哪种表示形式是很好的。

让我们更深入地挖掘缺失数据。

调查缺失数据

理想情况下,我们应该发现只有非必填问题会有缺失答案。这是否是情况?图 6.7 显示了显示每一列缺失值数量的表格快照:

survey.isnull().sum()

图

图 6.7 每一列的缺失值

这表明,例如,参与者的唯一标识符和年龄列都没有缺失数据。《MainBranch》列代表调查问题的答案,“以下哪个选项最能描述你现在的状态?在本调查中,开发者是指‘编写代码的人’。”年龄是我们感兴趣的问题之一,并且在调查中是必填的。到目前为止,一切顺利。碰巧的是,我们有一个机器可读格式的数据字典,因此我们可以将其与数据交叉引用,以查看哪些必填问题实际上有缺失数据。图 6.8 是数据字典数据集的快照:

data_dict = pd.read_csv("./data/survey_results_schema.csv")
data_dict.head(10)

图

图 6.8 数据字典数据集的快照

我们有一个qname列,它应该与我们的答案数据中的列名相匹配。我们还有问题本身,似乎以文本或 HTML 形式呈现,以及问题是否为必填项,在force_resp列中表示。看起来我们已经有足够的信息来交叉引用这些文件,但检查我们对qname列的假设是值得的。Python 中的一个巧妙技巧是使用集合比较列名。

集合论在数据科学课程中并不常见,但了解一些关于集合的技巧,这些集合只包含唯一值,可以在这种特定情况下有所帮助。我们将创建一个包含我们答案数据中的列名和数据字典数据集中qname列的值的集合,并查看它们在哪里重叠。

len(set(survey.columns).intersection(set(data_dict["qname"])))

输出结果是 50,这意味着有 50 个列同时出现在两个集合中。这很有希望,但我们还想知道哪些列没有重叠。在 Python 中从另一个集合中减去一个集合实际上给出了这个差异。此代码的输出是我们答案数据中 34 个未在数据字典中考虑的列的集合,其中图 6.9 显示了这些列的样本。

set(survey.columns) - set(data_dict["qname"])

figure

图 6.9 数据字典中未考虑的列样本

检查时,许多这些看起来像是相同问题的多项选择题选项。例如,调查问题,“你目前正在使用 AI 工具开发工作流程的哪些部分,以及你接下来一年内感兴趣使用 AI 工具的部分有哪些?请选择所有适用的选项,”有“目前正在使用”、“感兴趣使用”和“不感兴趣使用”的复选框选项,这些答案记录在三个列中。图 6.10 和 6.11 分别显示了问题在调查中的格式以及答案在数据中的表示。

figure

图 6.10 调查中出现的关于 AI 工具的问题

调查中每个复选框列的对应数据中都有一个列,其值是该列中勾选的所有 AI 用例。也就是说,图 6.11 中第二行表示的参与者表示他们目前正在使用 AI 工具编写代码、提交和审查代码。

figure

图 6.11 调查答案数据中相同问题的表示

看起来我们无法轻松地将所有列与数据字典匹配,因此另一个选项是查看数据字典中强制性的内容,并检查所有相关列是否有任何缺失答案数据。图 6.12 显示了根据数据字典的强制性列:

data_dict[data_dict["force_resp"] == True]

figure

图 6.12 根据数据字典的强制性列

至少,我们答案数据中的每一行都应该有年龄、最高教育水平、国家、货币以及参与者当前是否在其工作流程中使用 AI 工具的值。因此,我们不应该在EdLevel列中有缺失数据。是这样吗?图 6.13 显示了以下代码的输出:

survey[survey["EdLevel"].isnull()]

figure

图 6.13 教育水平缺失的行,这种情况绝不应该发生

显然,强制性问题存在缺失答案。这里的一个可疑元素是,当EdLevel缺失时,每个答案似乎都缺失。也许这些是完全错误的例子。让我们确定对于每个在EdLevel列缺失的参与者,缺失的列数有多少。图 6.14 显示了输出:

survey[survey["EdLevel"].isnull()].isnull().sum(axis=1).sort_values()

figure

图 6.14 EdLevel 缺失时每行的缺失值数量

这告诉我们,无论EdLevel缺失在哪里,都有精确的 80 个缺失值,这意味着这些是所有答案都缺失的调查响应,可以安全地删除。具体来说,任何所有调查答案列都缺失的地方都可以删除:

survey = survey.dropna(subset=survey.columns[4:], how="all")

让我们重新审视我们的交叉引用,并查看我们答案数据中所有没有缺失值的列。我们可以手动与强制性问题进行交叉检查,使用图 6.12,并查看是否有任何问题的对应列有缺失数据。以下代码生成了图 6.15 所示的输出:

survey.isnull().sum().loc[lambda x: x==0]

figure

图 6.15 无缺失数据的列列表

如果我们将此与图 6.12 中的表格进行交叉引用,似乎Currency列是唯一剩下的强制性问题,且存在缺失数据。让我们再次看看缺失数据中是否存在某种模式。当Currency缺失时,是否有相同数量的缺失值?图 6.16 显示了以下代码的输出:

survey[survey["Currency"].isnull()].isnull().sum(axis=1)

figure

图 6.16 当Currency缺失时每行的缺失值数量

这里似乎没有明显的模式。它表明,尽管强制性Currency问题有超过 20,000 个缺失答案,这可能是某种技术错误。也许可能存在其他因素影响这一点,例如某些国家货币数据缺失更频繁。无论如何,我们的初步研究问题不依赖于这个列,所以这是一个好的地方来决定现在不再进一步追究这一线索。让我们总结到目前为止的过程。我们的第一个决策点是调查并删除缺失值,如图 6.17 所示。

figure

图 6.17 我们分析的第一步,可视化展示

现在,让我们更详细地探索我们的 AI 相关列。

6.4.2 分析分类调查数据

首先,我们应该了解有多少比例的人甚至在使用 AI 工具。一张快速图表揭示了这一答案,如图 6.18 所示:

fig, axis = plt.subplots()

(
    survey["AISelect"]
    .value_counts(dropna=False)
    .plot
    .barh(ax=axis)
)

axis.set(title="Answers to the question:\n'Do you currently
↪ use AI tools in your development process?'",
         xlabel="Number of responses")

plt.show()

figure

图 6.18 参与者是否使用 AI 工具的回答分布

从这里我们可以继续探索很多方向。例如,我们可以找出 AI 工具用户和那些不使用它们的人的群体或工作角色。然而,由于我们专注于最终结果,我们可以意识到这并不是我们试图回答的问题。我们只对已经使用 AI 工具的子群体感兴趣,我们更关注他们是如何使用它们的。话虽如此,我们感兴趣的角度是人们如何看待 AI 工具的可信度,因此看看使用 AI 和不使用 AI 的参与者之间是否存在态度差异可能很有趣。

调查调查答案中的 Likert 数据

对于 AI 用户对工具的感受问题,我们首先想使用AISent列,它揭示了图 6.19 中显示的问题的答案。

figure

图 6.19 关于 AI 情感的问题

现在,我们可以查看AISent列的分布,以确定人们是如何回答的。由于这个问题不是强制的,我们预计会找到一些缺失数据。以下代码生成了图 6.20 所示的输出:

survey["AISent"].value_counts(dropna=False)

figure

图 6.20 AI 情感问题的答案分解

如预期的那样,有很多缺失答案。这些并不是坏数据;总的来说,它们代表的是选择不回答此问题的参与者,因此我们可以用占位符,如“未给出答案”来填充这个值。另一个,更具体的 Python 步骤是我们可以将此列的数据类型明确设置为分类类型。这将允许我们设置值的顺序,这样无论它们出现在数据表或图表中,都不会按字母顺序排序。我们使用pandasCategorical数据类型来完成所有这些。以下代码的输出显示在图 6.21 中:

survey["AISent"] = (
    pd.Categorical(
        survey["AISent"].fillna("No answer given"),
        categories=['No answer given', 'Unsure',
                    'Very unfavorable', 'Unfavorable',
                    'Indifferent', 'Favorable', 'Very favorable'],
        ordered=True)
)

survey["AISent"].value_counts(dropna=False).sort_index()

figure

图 6.21 将数据类型转换为Categorical后的 AI 情感答案分解

我必须做出的一个选择是值的顺序。这主要是我的个人偏好,我选择将“未给出答案”选项放在第一位,并从负面到正面情感开始剩余的答案。然而,我们在这个阶段仍然必须做出这个选择。现在,我们可以比较这些答案在 AI 工具用户、非用户和潜在用户群体中的分布。

使用交叉表来交叉引用分类值

比较不同群体 AI 工具用户对喜好程度的答案,需要交叉引用两个分类变量。在pandas中,一个合适的方法是crosstab,以下代码生成了图 6.22 所示的输出:

pd.crosstab(
    index=survey["AISelect"],      #1
    columns=survey["AISent"]      #2
)

1 行表示参与者是否是 AI 工具用户。

2 列表示对 AI 情感问题的答案。

figure

图 6.22 比较 AI 用户和非用户是否对 AI 工具持好感

我们可以立即注意到,那些声称不使用 AI 的人也没有给出后续回答,要么是因为后续回答没有意义,要么是因为调查主动隐藏了该选项。我们可以在这里停下来,并尝试进一步理解这个表格,因为它并不太大。然而,我倾向于不要长时间盯着数字表格,而是创建可视化图表。

使用热图可视化交叉表

使用热图可视化数字表通常是好机会,seaborn库可以提供这种功能。我选择了灰度选项,这对应于打印,但任何顺序色图都可以。

关于颜色尺度术语的说明

当决定可视化的颜色,并且涉及多种颜色时,我们需要选择一个色图,即可视化中出现的颜色范围。我们的选择有顺序、分散或分类(也称为名义或定性)。

顺序色图在亮度上逐渐变化,以表示连续的尺度。亮度与测量的大小成比例;一个例子是灰度色图,如图 6.23 中使用的色图。较深的颜色表示更多的人落入某个类别。

分散色图包括多种颜色,这些颜色根据方向逐渐改变亮度。亮度仍然用来表示强度,但色调也指示方向。一个例子是负值用红色表示,正值用蓝色表示,较大的值分别用较深的红色或蓝色表示。

分类色图用于需要不同部分的可视化具有不同颜色的情况。它们通常用于饼图或柱状图中,其中每个切片或柱子都是完全不同的颜色。颜色纯粹是为了视觉分离,不应进行数值解释。

我还把最小和最大点分别锚定在 0 和 1,因为我们正在使用的尺度就是这样。图 6.23 显示了生成的热图:

fig, axis = plt.subplots()

sns.heatmap(
    data=pd.crosstab(
        index=survey.loc[survey["AISelect"]
↪ != "No, and I don't plan to", "AISelect"],
        columns=survey.loc[survey["AISelect"]
↪ != "No, and I don't plan to", "AISent"],
        normalize="index"
    ),
    cmap="Greys",
    vmin=0,
    vmax=1,
    square=True,
    annot=True,
    ax=axis
)

fig.suptitle("How favorably do different people view AI tools?")

axis.set(
    xlabel="How favorable is your stance on using AI tools?",
    ylabel="Do you currently use AI tools?"
)

plt.show()

figure

图 6.23 展示了用户和潜在用户对 AI 工具的喜好热图

注意:这是一个将一些编程最佳实践纳入我们分析的好机会。我们无疑会创建更多像这样的热图,它们之间的区别将是数据和轴标签。其他选项将保持不变,因此我们应该创建一个可重用的函数,可以生成热图而无需重复代码。代码质量问题通常不在此类项目的范围内。

生成图 6.23 所示热图的修改后的代码如下。我们将在整个解决方案中重用这个create_heatmap方法:

def create_heatmap(data, square=True):     #1
    fig, axis = plt.subplots()

    sns.heatmap(
        data=data,
        cmap="Greys",
        vmin=0,
        vmax=1,
        square=square,
        annot=True,
        ax=axis
    )

    return fig, axis     #2

fig, axis = create_heatmap(
    pd.crosstab(
        index=survey.loc[survey["AISelect"]
↪ != "No, and I don't plan to", "AISelect"],
        columns=survey.loc[survey["AISelect"]
↪ != "No, and I don't plan to", "AISent"],
        normalize="index"
    )
)

fig.suptitle("How favorably do different people view AI tools?")

axis.set(
    xlabel="How favorable is your stance on using AI tools?",
    ylabel="Do you currently use AI tools?"
)

plt.show()

1 该函数只需要数据表,以及一个可选的方式来使热图成为正方形或不是。

2 我们返回 Figure 和 Axis 对象,以便自定义轴标签和标题。

现在,回到解释热图。似乎当前和潜在 AI 用户之间存在一些明显的差异。首先,使用 AI 工具的人对它们的看法比潜在用户更积极,而尚未使用 AI 工具的人更有可能对它们持中立态度。这可能并不令人惊讶。对工具缺乏怀疑态度是有趣的;似乎很少有开发者对工具持负面看法。这可能是一种选择偏差;填写此调查的开发者可能已经更有可能拥有良好的 AI 工具用例,因此有更高的评价。让我们看看这些群体在关于工具输出可信度的问题上的表现如何。

AIBen列也存在缺失数据,因此我们选择填充这些数据并再次创建一个有序的Categorical列。然后,以下代码生成了图 6.24 中的输出:

survey["AIBen"] = (
    pd.Categorical(
        survey["AIBen"].fillna("No answer given"),
        categories=['No answer given', 'Highly distrust',
                    'Somewhat distrust', 'Neither trust nor distrust',
                    'Somewhat trust', 'Highly trust'],
        ordered=True
    )
)

survey["AIBen"].value_counts(dropna=False).sort_index()

图片

图 6.24 AI 工具的可信度分布

尽管大多数参与者对 AI 工具持积极看法,但在信任它们的输出方面,分布更为广泛。通过以下代码比较 AI 用户和潜在用户这些答案的交叉表,如图 6.25 所示:

pd.crosstab(
    index=survey["AISelect"],
    columns=survey["AIBen"]
)

图片

图 6.25 比较不同用户对 AI 可信度的看法

再次,我们可以排除非用户,并生成交叉表的热图,如图 6.26 所示:

fig, axis = create_heatmap(
    pd.crosstab(
        index=survey.loc[survey["AISelect"]
↪ != "No, and I don't plan to", "AISelect"],
        columns=survey.loc[survey["AISelect"]
↪ != "No, and I don't plan to", "AIBen"],
        normalize="index"
    )
)

fig.suptitle("How much do different people trust the output of AI tools?")

axis.set(
  xlabel="How much do you trust the accuracy of the output from AI tools?",
  ylabel="Do you currently use AI tools?"
)

plt.show()

图片

图 6.26 比较 AI 工具用户和潜在用户对 AI 可信度的热图

AI 工具的活跃用户对其输出的信任度更高,不信任度更低,尽管很少有人愿意说他们高度信任它们。潜在用户则更犹豫不决,更频繁地回答“既不信任也不怀疑”。这并不令人惊讶,但验证了一些基本假设。

在继续调查数据的各个方面之前,让我们回顾一下到目前为止的过程。图 6.27 显示了迄今为止采取的步骤。

图片

图 6.27 可视化到目前为止的分析

现在,是时候深入探讨人们使用 AI 工具的目的了。

将分类数据转换为指示变量

对于这部分内容,我们将只关注那些回答了他们是否使用这些工具的问题的人。让我们回顾一下关于他们使用 AI 工具的开发工作流程哪些部分的答案格式。图 6.28 显示了相关调查问题的部分内容。

图片

图 6.28 关于参与者使用 AI 工具的调查问题的快照

图 6.29 展示了以分号分隔的字符串形式表示的答案列的快照。数据是通过以下代码选择的:

(
    survey.loc[survey["AISelect"] == "Yes", "AIToolCurrently Using"]
    .dropna()
    .head(10)
)

图片

图 6.29 存储人们如何使用 AI 工具的答案的数据快照

这不是我们可以直接处理的格式。通常,我们会将可用的答案分散为指示变量,即二元列,以表示每个可能的答案是否被选中。接下来的这一步,即独热编码,其中我们将这个字符串列转换为指示变量,是一个纯粹的技术步骤,如果你不知道如何使用现有工具进行转换,那么转向 AI 工具是一个完美的选择。让我们看看它是如何运作的。

我向 ChatGPT 提出了以下问题:

我有一个包含调查问题中多选题选项值的 pandas DataFrame 列。这些值不是互斥的,因此它们可以包含由分号分隔的多个答案。一个示例值将是“选项 1; 选项 2; 选项 3。”我想将这些值转换为多个指示列,每个值一个,所以我应该有名为“选项 1”、“选项 2”等列,每个列都是一个表示该值是否存在于原始列中的二元指示器。pandas 的代码是什么?

它的答案以,“你可以通过使用 pandas 中的str.get_dummies方法来创建多选题列中的每个选项的指示列”开始,这正好是我要找的方法。它还提供了一些代码片段,如图 6.30 所示。

figure

图 6.30 ChatGPT 建议的代码片段

我必须进行的一个调整是将分隔符从“; ”改为“;”,因为我们的数据中分号分隔的值之间没有尾随空格。除此之外,str.get_dummies函数正是我们所需要的。以下代码生成了图 6.31 中显示的指示列:

ai_tool_indicators = (
    survey.loc[survey["AISelect"] == "Yes", "AIToolCurrently Using"]
    .str.get_dummies(sep=";")
)
ai_tool_indicators

figure

图 6.31 新创建的指示列快照

再次强调,每一列代表图 6.28 中的其中一个复选框行,而值为 1 表示在“目前正在使用”列下勾选了复选框。如果我们对“感兴趣使用”和“不感兴趣使用”也进行重复操作,我们将得到三倍的指示变量,实际上每个问题下每个复选框对应一个列。

经验可能会告诉我们,在分隔定界字符串时,我们可能会在列名中出现尾随空格,所以让我们检查并修复这个问题:

ai_tool_indicators.columns = [c.strip() for c in ai_tool_indicators.columns]

指示变量的一个好处是,每一列的总和代表勾选特定复选框的人数,每一列的平均值代表百分比。让我们看看检查了每个选项的参与者的百分比。记住,现在这仅仅是告诉我们他们目前使用 AI 工具做什么的 AI 用户。以下代码生成了图 6.32 中的图表:

fig, axis = plt.subplots()

(
    ai_tool_indicators
    .mean()
    .sort_values()
    .plot
    .barh(ax=axis)
)

axis.set(
    title="What are AI users using their AI tools for?",
    xlabel="% of users who ticked that option"
)

plt.show()

figure

图 6.32 显示了使用 AI 工具的百分比的用户勾选了每个“目前正在使用”选项的条形图

大多数开发者都在使用 AI 工具编写和调试代码,但还有其他相关的用例也很受欢迎,例如测试代码、项目规划和甚至编写代码文档。我们可以将指标列添加到我们的原始数据中,并保留一个仅包含 AI 用户的过滤数据集,用于剩余的调查。图 6.33 显示了以下操作后的数据快照:

survey_ai_users = (
    pd.concat([survey, ai_tool_indicators], axis=1) #1
    .dropna(subset=ai_tool_indicators.columns, how="any")
)
assert len(survey[survey["AISelect"] == "Yes"]) == len(survey_ai_users)

survey_ai_users.head()

1 concat默认会根据索引进行匹配。

figure

图 6.33 AI 用户合并数据的快照

让我们看看参与者根据他们使用工具的目的来判断 AI 的喜好和可信度。我们的“从结果开始”的哲学在这里很有帮助。我们想要一个表格,其中每一行是 AI 工具的一个用例(例如,“编写代码”),每一列是喜好选项之一。每个单元格代表那些声称他们使用 AI 工具进行特定目的并针对喜好给出特定回答的人的数量,或者更好的是,百分比。这应该会显示不同用户之间是否存在意见差异。

将数据转换为长格式以便于分析

将不同的使用案例与喜好意见进行交叉引用需要对我们的数据进行一些调整。这又是一个 AI 工具告诉我们如何做到这一点的用例,但这次,让我们自己尝试一下,并在之后与我们的 AI 工具比较笔记。我们想要的是长格式的数据,这将使生成正确的交叉表变得更容易。有关数据建模中宽数据与长数据的更多详细信息,请参阅第三章。以下代码创建了一个与喜好交叉引用的多个选择题之一的长格式版本,输出显示在图 6.34 中:

(
    survey_ai_users[survey_ai_users["Collaborating with teammates"] == 1]
    .groupby("AISent")
    .size()
    .reset_index(name="count")
    .assign(option="Collaborating with teammates")
)

figure

图 6.34 使用 AI 工具进行协作的人的喜好

如果我们为每个选项都有一个组合成一个长表的数据库,我们就可以将其重塑为我们需要的交叉表。让我们遍历选项并构建这个长表:

tool_favorability_dfs = []
for col in ai_tool_indicators.columns:      #1
    option_df = (
        survey_ai_users[survey_ai_users[col] == 1]
        .groupby("AISent")
        .size()
        .reset_index(name="count")
        .assign(option=col)
    )
    tool_favorability_dfs.append(option_df)     #2

options_vs_favorability = pd.concat(     #3
    tool_favorability_dfs,
    axis=0,
    ignore_index=True
)

print(options_vs_favorability.shape)

1 遍历所有指标

2 生成类似于图 6.32 的聚合,并在列表中跟踪

3 将列表合并到一个 DataFrame 中

输出是(70, 3),这意味着我们现在有 70 行,对应于 10 个指标列,每个列有 7 个可能的喜好答案。现在我们可以使用这些数据创建一个百分比交叉表,正如计划的那样。以下代码执行此操作,并在图 6.35 中生成交叉表:

favorability_crosstab = (
    pd.crosstab(index=options_vs_favorability["option"],
                columns=options_vs_favorability["AISent"],
                values=options_vs_favorability["count"],
                aggfunc="sum",
                normalize="index")
)
favorability_crosstab

figure

图 6.35 喜好与不同 AI 工具用例的交叉表

现在是一个难以理解的表格,所以让我们也生成一个热图。最终的热图显示在图 6.36 中:

fig, axis = create_heatmap(
    favorability_crosstab.round(2)
)

fig.suptitle("How favorably do people who use AI for different purposes
↪ view AI tools?")

axis.set(xlabel=None, ylabel=None)

plt.show()

figure

图 6.36 比较不同 AI 工具用例的喜好热图

这比直接的数据表更容易解释。在使用案例之间似乎没有太大的差异,但我们还是可以得出一些观察结果:

  • 即使我们将数据分解到使用案例中,AI 用户通常也会对他们的工具持积极态度。

  • 那些使用这些工具进行项目规划、协作、提交和审查代码以及部署和监控的人,将 AI 工具评为“非常有利”的最多。

  • 最大的“中立”类别是“其他”,但我们没有底层数据,因此无法进一步调查这一点。

在继续之前,让我们简要地看看 ChatGPT 是如何建议我们解决这个特定的技术挑战的。

使用 AI 工具改进我们的分析

每当某件事需要两到三个离散步骤时,我都会觉得有更简单的方法来做,这正是 AI 工具可以帮助我们提高技术知识的地方。

注意:重要的是要记住,像 ChatGPT 这样的 AI 工具只是工具。这使得它们随着时间的推移可以互换。你可能不会使用这个特定的 AI 工具,而且无疑未来还会创造出更先进的工具。你可以自由地将所有关于 ChatGPT 的引用替换为你偏好的任何 AI 工具。

在这种情况下,ChatGPT 的回答非常相似;它建议通过遍历选项来构建 DataFrame。图 6.37 显示了 ChatGPT 的回答。我没有整合它的建议或深入挖掘它与我的代码之间的具体差异;我所寻找的只是是否有一个我忽视的单一pandas方法来完成这种转换。结果证明,AI 工具在遍历不同选项方面做得并不比这更好。

figure

图 6.37 ChatGPT 创建选项与喜好交叉表的方法

之前,关于输出可信度的问题产生了更有趣的发现,因此让我们也为AIBen列重复最新的交叉表和热力图过程。以下代码生成长格式数据,其快照显示在图 6.38 中:

tool_trust_dfs = []
for col in ai_tool_indicators.columns:
    option_df = (
        survey_ai_users[survey_ai_users[col] == 1]
        .groupby("AIBen")
        .size()
        .reset_index(name="count")
        .assign(option=col)
    )
    tool_trust_dfs.append(option_df)

options_vs_trust = pd.concat(tool_trust_dfs, axis=0, ignore_index=True)

print(options_vs_trust.shape)
options_vs_trust.head()

figure

图 6.38 比较 AI 工具使用案例选项与可信度的长格式数据快照

这生成了 60 行数据,10 个使用案例,每个案例有六个可信度选项。现在,我们可以创建以下代码生成的交叉表和热力图。最终的热力图显示在图 6.39 中:

trust_crosstab = (
    pd.crosstab(index=options_vs_trust["option"],
                columns=options_vs_trust["AIBen"],
                values=options_vs_trust["count"],
                aggfunc="sum",
                normalize="index")
)

fig, axis = create_heatmap(
    trust_crosstab.round(2)
)

fig.suptitle("How much do people who use AI for different purposes
↪ trust the output of AI tools?")

axis.set(xlabel=None, ylabel=None)

plt.show()

figure

图 6.39 不同 AI 工具使用案例与可信度视图的热力图

从这个热力图中可以看出,大多数用户“有些信任”AI 工具的输出,在使用案例之间没有太大的差异。关于“其他”类别的数据将会很有趣,因为那里的可信度变化最大。

注意:使用这种多选题数据的一个局限性是我们对那些表示他们使用 AI 超过一个目的的人进行了双重计数。这对我们的分析来说不是决定性的,但了解我们的类别不是互斥的总是好的。

在继续我们的分析之前,让我们回顾一下到目前为止的步骤,包括最新的一个,我们调查了不同用例中关于 AI 的意见。我们最新的步骤如图 6.40 所示。

figure

图 6.40 分析过程中至调查 AI 工具使用所采取的步骤

接下来,我们对 AI 工具用户的个人资料(例如,他们有什么工作头衔)感兴趣。

为了清晰起见,重新分类数据

在这些数据中,我们可以进一步探索更多的细微差别。声称使用 AI 编写代码的人可能出于非常不同的目的使用它。尽管名称如此,这份调查并不仅仅由开发者完成。还包括其他工作角色,因此值得探讨这些角色对 AI 的态度是否有所不同。首先,让我们看看使用 AI 工具的人DevType列中的值分布,但仅限于这些人。以下代码产生的结果如图 6.41 所示:

survey_ai_users['DevType'].value_counts(dropna=False)

figure

图 6.41 使用 AI 工具的参与者中工作角色的分布

如我们所预期,大多数参与者是开发者,其他工作从数据角色到研究、营销,甚至管理,形成了一个长长的尾巴。为了我们的目的,我们可能可以将这些类别中的几个合并在一起,至少在第一次迭代中是这样。为了确定不同的工作角色是否以不同的方式使用 AI,我们可以一开始就将所有开发者归入一个单独的组。让我们看看哪些组可以归入我们更大的“开发者”组。以下代码的结果如图 6.42 所示:

devtypes = (
    survey_ai_users
    .dropna(subset=["DevType"])
    .query("DevType.str.startswith('Developer')")
    .loc[:,"DevType"]
)

devtypes.value_counts()

figure

图 6.42 AI 工具用户中不同开发者角色的分布

大多数这些角色可以归入一个单独的“开发者”类别,除了最后两个:“开发者经验”和“开发者倡导者”。这些是与开发者相邻的角色,更侧重于人际交往,例如创建和参与社区,而不一定需要编写大量代码。让我们将其他开发者角色归入一个单独的类别。具体来说,在 Python 中,我们可以创建一个字典,将每个角色与它们应属于的“新”类别匹配:

devtype_map = {}

dev_exclusions = ["Developer Experience", "Developer Advocate"]

dev_devtypes = [col for col in devtypes.value_counts().index
↪ if col not in dev_exclusions]

for col in dev_devtypes:
    devtype_map[col] = "Developer"

再次查看图 6.42 中的列表,我们看到一些可以归为一组的工程角色,因此我们也这样做。以下代码产生的输出如图 6.43 所示:

eng_devtypes = (
    survey_ai_users
    .dropna(subset=["DevType"])
    .query("DevType.str.contains('engineer', case=False)")
    .loc[:,"DevType"]
)

eng_devtypes.value_counts()

figure

图 6.43 AI 工具用户中工程角色的分布

目前,所有这些角色都可以归入一个“工程师”类别,除了管理类。让我们进行这个更改,然后查看我们新的、更紧凑的工作类别列的分布。以下代码产生的结果如图 6.44 所示:

for col in ['Engineer, data', 'Cloud infrastructure engineer',
            'Engineer, site reliability', 'Hardware Engineer']:
    devtype_map[col] = "Engineer"

survey_ai_users["job_category"]
↪ = survey_ai_users["DevType"].replace(devtype_map)

survey_ai_users["job_category"].value_counts(dropna=False)

figure

图 6.44 新的job_category列的分布

现在,我们可以创建一个类似于我们之前创建的交叉表,这次是为了将职位角色与 AI 用例进行交叉引用。首先,我们将数据转换为长格式,然后创建我们的交叉表。最后,我们创建一个热图,以便更容易地调查结果。以下代码执行这些步骤,并在图 6.45 中生成热图:

tool_job_dfs = []

for col in ai_tool_indicators.columns:      #1
    option_df = (
        survey_ai_users[survey_ai_users[col] == 1]
        .dropna(subset="job_category")
        .groupby("job_category")
        .size()
        .reset_index(name="count")
        .assign(option=col)
    )
    tool_job_dfs.append(option_df)

options_vs_jobs = pd.concat(tool_job_dfs, axis=0, ignore_index=True)
job_crosstab = (     #2
    pd.crosstab(index=options_vs_jobs["option"],
                columns=options_vs_jobs["job_category"],
                values=options_vs_jobs["count"],
                aggfunc="sum",
                normalize="columns")
    .transpose()
)

fig, axis = create_heatmap(      #3
    job_crosstab.round(2),
    square=False
)

fig.suptitle("What do different job roles use AI for?")

axis.set(xlabel=None, ylabel=None)

plt.show()

1 将职位类别与 AI 用例进行交叉引用。

2 转换为交叉表

3 最后,绘制热图

figure

图 6.45 显示不同职位角色间 AI 用例的热图

热图让我们对使用模式有一些了解:

  • 大约 30%的人使用 AI 工具编写代码,数据库管理员、市场营销和销售专业人士以及设计师是例外。

  • 数据库管理员比其他人更常使用 AI 来了解代码库。除了他们之外,其他最常使用 AI 的职位通常是编写代码较少的,如市场营销或产品职能。

  • 虽然开发者倡导者也使用 AI 来编写代码,但他们也在使用 AI 进行文档方面得分最高,这可能是角色的重要方面。

除了这些观察结果之外,参与者似乎在使用 AI 工具的方式上或多或少是相同的。让我们再次回顾我们的步骤,包括这个最新的调查职位角色的步骤。图 6.46 显示了最新的步骤图。

figure

图 6.46 显示我们分析中采取的最新步骤图

注意,一旦我们清理了 AI 情感和信任相关的列,我们的分析可以并行进行。图 6.46 说明了这一点,因为在调查 AI 用户的职位角色之前,没有必要比较 AI 工具的使用和信任以及好感度。这突出了分析有时不是完全线性的事实。

让我们导出我们将在下一章进一步探索问题的数据:

survey.to_parquet("../chapter-7/data/survey.parquet.gz",
↪compression="gzip", index=False)
survey_ai_users.to_parquet("../chapter-7/data/survey_ai_users.parquet.gz",
↪compression="gzip", index=False)

现在,让我们回顾到目前为止我们所完成的一切,并确定在下一章中为了达到最小可行答案我们还需要做什么。

6.4.3 到目前为止的项目进度

到目前为止,我们已经回答了两个主要利益相关者问题中的一个,即开发者的意见是否取决于他们的经验、职位角色以及他们具体使用工具的目的。我们分析了调查结果,得出结论:

  • AI 工具的用户通常比非用户对其评价更高。

  • AI 工具的用户也比非用户表示的更信任工具的输出。

  • 帮助编写和调试代码是 AI 工具最受欢迎的用例。

  • 不同职位的人报告说他们使用 AI 工具的目的不同。

在下一章中,我们将通过解决第二个利益相关者假设来继续项目,该假设是新旧程序员使用这些工具的方式不同。为此,我们需要结合连续数据和分类数据的方法,并执行适当的统计测试。

摘要

  • 分类数据在现实世界中很普遍。

  • 连续数据的方法通常不适用于分类数据;因此,了解处理分类数据的方法对于分析师来说很重要。

  • 像单热编码这样的方法将分类数据转换成更适合分析的形式。

第七章:7 分类数据:高级方法

本章涵盖

  • 在分析中结合连续数据和分类数据

  • 在适当的情况下将连续数据转换为分类数据

  • 使用高级方法(如统计测试)分析分类数据

在本章中,我们将继续探索分类数据的价值。在第六章中,我们探索了主要是分类数据的调查数据,并使用适合分类数据的方法回答了一些利益相关者的问题。提醒一下,该项目的数据和示例解决方案文件可在davidasboth.com/book-code找到。

本章深入探讨了更高级的方法:对分类数据进行统计测试以及结合连续数据和分类数据。我们首先回顾上一章的项目概述,然后总结到目前为止的工作,再继续分析。

7.1 项目 5 回顾:分析调查数据以确定开发者对 AI 工具的态度

回顾一下,我们正在分析 Stack Overflow 开发者调查,以确定编码者如何使用人工智能工具。我们的利益相关者对测试他们的两个假设感兴趣:

  • 新手和经验丰富的编码者使用这些工具的方式不同。

  • 人们对于当前人工智能工具的有用性和可信度的看法取决于他们的经验、工作角色以及他们具体使用这些工具的目的。

我们通过探索、重塑和总结调查数据中的分类列来回答了第二部分。下一节将描述可用的数据。

7.1.1 数据字典

该数据字典分为两部分,均包含在补充材料中:

  • 有一个按问题分解的列表,survey_results_schema.csv,记录了哪个列包含答案。一些问题的答案分布在多个列中。

  • 此外,还有调查本身的 PDF 副本。通过这个副本,你可以直接观察数据生成过程,这是很少见的。

让我们回顾一下我们的预期结果,以便我们知道要朝着什么结果努力。

7.1.2 预期结果

我们的利益相关者有特定的假设,我们的分析应该集中在它们上。我们已经对数据进行了一些一般性的探索,以识别如缺失值等元素。我们最小可行答案应该是支持或反驳主要假设的证据。因此,

  • 我们的结论应该包括不同经验水平之间在人工智能工具使用上是否存在差异。

  • 我们应该传达影响人们对于人工智能工具看法和信任度的因素,这些因素得到了调查数据的支持。

在继续我们的分析之前,让我们回顾一下上一章我们所做的工作。

7.1.3 到目前为止的项目总结

在第六章中,我们

  • 探索了调查问题,以找到与我们的分析相关的那些问题

  • 调查了缺失数据

  • 分析了 AI 用户和非用户对 AI 工具友好度的信任和观点

  • 探讨了 AI 用户使用这些工具的不同目的以及不同的使用目的如何影响用户的信任度

  • 调查了受访者的不同工作角色以及信任度和好感度在这些角色中的变化

图 7.1 展示了到目前为止的工作。

figure

图 7.1 上一章中我们的分析步骤

在本章中,我们将回答关于受访者经验水平的问题。我们感兴趣的是,更多的编码经验是否会改变对 AI 工具的看法,以及经验用户是否有与新手程序员不同的使用案例。

7.2 使用高级方法回答关于分类数据的进一步问题

有理由相信,经验更丰富的程序员比初级开发者需要从 AI 工具中获得不同类型的帮助。让我们看看 YearsCodePro 列中的值,该列询问受访者编程了多少年。以下代码的输出,该代码从上一章末尾导入了数据,如图 7.2 所示:

import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
survey = pd.read_parquet("./data/survey.parquet.gz")
survey_ai_users = pd.read_parquet("./data/survey_ai_users.parquet.gz")

survey_ai_users["YearsCodePro"].unique()

figure

图 7.2 YearsCodePro 的不同值

这列应该是数值类型,但由于“不到 1 年”和“超过 50 年”这两个值,实际上是以文本形式存储的。由于其余的值没有分箱,我们应该将这个列转换为数值类型。我们可以将这两个文本值分别替换为 0 和 50,而不会对数据造成太大影响:

survey_ai_users["YearsCodePro"] = (
    survey_ai_users["YearsCodePro"]
    .replace({
        'Less than 1 year': 0,
        'More than 50 years': 50
    })
    .astype(float)
)

注意:在 pandas 的后续版本中,2.0 及以上版本实际上存在可空整数字段——即代表整数的数字,但也可以是缺失值。对于示例解决方案,出于兼容性原因,我使用的是 2.0 之前的版本,该版本只允许 float 类型,即小数,包含缺失值。

现在我们有一个名为 YearsCodePro 的数值列,让我们观察它的分布。结果直方图如图 7.3 所示:

survey_ai_users["YearsCodePro"].hist(bins=20)

figure

图 7.3 AI 用户编程经验的分布

大多数参与者的经验不到 10 年,尾部延伸到 50 年。现在,如果我们想测试这个连续值与另一个值之间的关系,我们可以简单地计算相关系数。然而,我们正在尝试将其与分类数据(例如,某人如何看待 AI 的好感度)进行比较,因此传统的相关系数不适用。一个想法是将经验直方图或类似分布的可视化(如箱线图)根据对好感度问题的不同回答进行分离。让我们看看使用 Python 库 seaborn 中的“boxenplots”的一个例子,这些类似于箱线图,但包含更多信息。以下代码生成了图 7.4 中的图表:

fig, axis = plt.subplots()

sns.boxenplot(
    data=survey_ai_users,
    x="YearsCodePro", y="AISent",
    color="gray",
    ax=axis
)

axis.set(
    title="Distribution of years of experience across
↪ answers to the favorability question",
    xlabel="Years of coding experience",
    ylabel="How favorably do you view AI tools?"
)

plt.show()

figure

图 7.4 根据对 AI 好感度问题的不同回答,经验分布情况

这个图表告诉我们,最高中位数的经验年份在不受欢迎的群体中。也就是说,那些对 AI 工具持负面看法的人平均来说是经验更丰富的程序员。然而,这个中位数并不比其他群体高很多,每个群体都有很大的变异性。现在让我们看看不同经验水平对 AI 工具输出的信任程度。以下代码生成了图 7.5 所示的箱线图:

fig, axis = plt.subplots()

sns.boxenplot(
    data=survey_ai_users,
    x="YearsCodePro", y="AIBen",
    color="gray",
    ax=axis
)

axis.set(
    title="Distribution of years of experience across
↪ answers to the trust question",
    xlabel="Years of coding experience",
    ylabel="How much do you trust the output of AI tools?"
)

plt.show()

图

图 7.5 经验年份在信任问题回答中的分布

再次,那些给出更负面回答的人平均来说经验更丰富。这种差异可能比喜好和信任更大,也表明经验更丰富的程序员不太可能信任此类工具的输出。我们真正想了解的是相反的问题:而不是不同答案中经验的分布,我们想知道,不同经验水平中不同答案的分布?

为了做到这一点,我们需要另一个交叉表到热图的转换过程,这次是比较经验年份和对于喜好和信任问题的回答。然而,我们只能这样做,如果“经验年份”被表示为分类值。

7.2.1 将连续值分箱到离散类别

我们首先将连续度量放入分类箱中。让我们总共创建六个逻辑箱,但请记住,你的箱选择将影响结果。箱可以手动创建,如下面的例子所示,也可以基于数据。一种方法可能是使用分位数,这可以确保每个箱有相同数量的数据。以下代码对数据进行分箱,并使用图 7.6 所示的输出验证每个箱内的最小和最大经验年份:

exp_bins = pd.cut(survey_ai_users["YearsCodePro"],
                  bins=[-1, 0, 2, 5, 10, 20, 50],
                  labels=["0", "1-2 years", "3-5 years",
                          "6-10 years", "11-20 years",
                          "over 20 years"]
                 )

survey_ai_users.groupby(exp_bins)["YearsCodePro"].agg(["min", "max"])

图

图 7.6 我们的YearsCodePro分组,每个组内的最小和最大经验值也显示

现在我们经验列是分类的,我们可以查看其分布。这通过以下代码实现,输出显示在图 7.7 中:

fig, axis = plt.subplots()

(
    exp_bins
    .value_counts()
    .sort_index()
    .plot
    .bar(ax=axis)
)

axis.set(
    title="Distribution of years of experience (binned)",
    xlabel="Years of experience",
    ylabel="Frequency"
)

plt.show()

图

图 7.7 新分箱的经验年份数据的分布

如图 7.7 所示,大多数参与者有不到 10 年的编码经验,其中 6-10 年是最常见的。与不同的 AI 工具用例不同,这个经验值在一个单独的列中,并没有分散在多个指标中。这使得我们的交叉表创建变得非常简单,因为我们只是交叉两个分类列。以下代码实现了这一点,并在图 7.8 中产生了输出:

exp_vs_sent = pd.crosstab(
    index=exp_bins,
    columns=survey_ai_users["AISent"],
    normalize="index"
)

exp_vs_sent

图

图 7.8 经验与对喜好问题的回答的交叉表

与前面的例子一样,这最好通过热图来探索,以下代码生成了图 7.9 所示的热图:

fig, axis = create_heatmap(   #1
    exp_vs_sent.round(2)
)

fig.suptitle("How favorably do people with different
↪ amounts of coding experience view AI?")

axis.set(
    xlabel=None,
    ylabel="Coding experience"
)

plt.show()

1 此函数在第六章中定义。

图

图 7.9 喜好与经验的热图

虽然 7.4 图显示那些对 AI 工具评价不佳的人平均经验更丰富,但当问题反过来时,大多数群体的答案分布似乎相当均匀。那么,对 AI 工具的信任度如何呢?以下代码创建了交叉表和散点图,后者如图 7.10 所示:

exp_vs_trust = pd.crosstab(
    index=exp_bins,
    columns=survey_ai_users["AIBen"],
    normalize="index"
)
fig, axis = create_heatmap(
    exp_vs_trust.round(2)
)

fig.suptitle("How much do people with different
↪ amounts of coding experience trust the output of an AI?")

axis.set(
    xlabel=None,
    ylabel="Coding experience"
)

plt.show()

图

图 7.10 编码经验与信任度对比的散点图

这个散点图描绘了一幅与图 7.5 类似的画面。似乎一个人的经验越丰富,平均来说,他们对 AI 工具输出的信任度就越低。这是一个考虑使用统计测试的好地方。我们想知道这个发现是否具有统计学意义。具体来说,我们的假设是“经验年数”的连续度量会影响一个人对 AI 工具输出信任度问题的回答。

7.2.2 使用统计测试处理分类数据

数据教育中涵盖的大多数统计测试都是为连续度量设计的。然而,有一些统计测试是专门针对分类数据或数据类型混合的。这是一个你可能知道但直到需要为项目使用时才具备所需特定知识的例子。因此,这是一个你可以求助于 AI 工具的问题。让我们看看当被问及“我可以使用什么方法来测量连续变量和有序变量之间的关联?”时,ChatGPT 会说什么。

它提供了多种方法,包括我们已执行的可视化。它用“如果你对关系的整体强度和方向感兴趣,斯皮尔曼秩相关或肯德尔 tau 系数可能很有用”来总结其建议。这些听起来像是开始的好地方。

这两种方法都是基于秩的,这意味着它们不直接使用序数值,而是使用它们在所有答案中的排名。这可能是你以前没有用你的特定工具做过的事情,AI 工具可以给你一个相关的代码片段。图 7.11 展示了当被提示提供计算 Python 中斯皮尔曼秩相关性的示例代码时,ChatGPT 的回答。

图

图 7.11 ChatGPT 计算斯皮尔曼秩相关性的代码片段

显然,这个代码片段不能直接使用,但只需稍作修改。让我们将 ChatGPT 的代码应用到我们自己的数据上,并计算斯皮尔曼秩相关性,以及肯德尔 tau 值,这可以通过类似的方式进行。这里有两个小点需要注意:我们必须丢弃任何缺失数据进行分析,并忽略那些对信任问题没有给出答案的情况,因为那个值不适合答案的序数性质。首先,我们将创建排名,然后计算两个统计指标。输出,最终的打印结果,如图 7.12 所示:

trust_exp_data = (
    survey_ai_users
    .dropna(subset=["YearsCodePro", "AIBen"], how="any")
    .loc[survey_ai_users["AIBen"] != "No answer given", :]
)

trust_rank = (
    pd.Series(
        trust_exp_data["AIBen"]
        .factorize(sort=True)[0]
    )
    .rank()
)

from scipy.stats import spearmanr, kendalltau

correlation, p_value = spearmanr(
    trust_exp_data["YearsCodePro"],
    trust_rank
)

print("Spearman's\n", correlation, p_value)

correlation, p_value = kendalltau(
    trust_exp_data["YearsCodePro"],
    trust_rank
)

print("Kendall's tau\n", correlation, p_value)

图

图 7.12 统计测试输出

在两次测试中,接近零的相关值意味着变量之间的关联较弱。两次打印输出中的第二个值是 p 值,它是结果显著性的代理。接近零的值意味着在基础假设,或“零假设”为真的世界中,我们极不可能看到这些结果。也就是说,在一个我们假设经验与信任分数之间没有关联的世界中,我们计算出关联值的可能性有多大?这个可能性越低,我们越有信心我们的相关值是稳健的。低 p 值意味着我们倾向于拒绝零假设,这意味着我们接受我们发现的关联,或缺乏关联,在统计上是显著的。

在这种情况下,统计测试告诉我们有显著证据表明经验与信任分数之间没有联系。相关值是负的,这会暗示经验用户对 AI 的信任度更低,但值接近零,意味着关联较弱。我们仍然可以进一步探索这个关联,但如果利益相关者坚持要求了解统计显著性,我们已经有了一个答案。

让我们回顾到目前为止的过程,包括构成我们分析并行分支,如图 7.13 所示。

图

图 7.13 到目前为止的分析,包括多个并行分支

在总结我们的结果之前,最后一个要探究的问题是查看开发者不感兴趣使用 AI 工具做什么。

7.2.3 从头到尾回答新问题

看看开发者不感兴趣的是什么,需要我们在这个项目中迄今为止所获得的所有技能。调查特别允许参与者为每个用例是否是他们不感兴趣的事项勾选一个复选框,这样我们就可以直接查看这些数据。这将有助于确定是否存在实际的市场机会。由于对这个问题的答案的记录方式与参与者目前使用 AI 工具的答案记录方式相同,我们可以按照第六章中所示的过程进行:

  1. 为每个 AI 用例创建指标变量(这次来自AIToolNot interested in Using列)。

  2. 仅将指标添加到 AI 用户的调查数据中。

  3. 创建一个长格式数据集,将工作角色与人们对不感兴趣的用例进行交叉引用。

  4. 使用这个长格式数据集创建一个交叉表。

  5. 将这个交叉表可视化为一个热图。

让我们从指标变量开始,之后我们可以查看人们对哪些用例最不感兴趣,如图 7.14 中的条形图所示:

ai_not_interested_indicators = (
  survey.loc[survey["AISelect"] == "Yes", "AIToolNot interested in Using"]
  .str.get_dummies(sep=";")
)

fig, axis = plt.subplots()

(
    ai_not_interested_indicators
    .mean()
    .sort_values()
    .plot
    .barh(ax=axis)
)

axis.set(
    title="What do AI users NOT want to use AI for?",
    xlabel="% of participants who ticked that option"
)

plt.show()

图

图 7.14 人们最不感兴趣使用 AI 的用例

前面的发现,例如前一章图 6.45 中的热力图,显示很少有人使用 AI 进行协作,图 7.14 也加强了这一点。参与者明确表示他们不打算使用 AI 工具与人类进行协作,大约四分之一的人也对使用它们进行项目规划或部署和监控不感兴趣。似乎有一些特定的任务,人们不愿意委托给 AI 工具。最后,让我们进行我们的长格式数据转换,得到一个热力图,按职位角色分解这一最后分析。这个最终热力图如图 7.15 所示:

ai_users_not_int = (
    pd.concat([survey, ai_not_interested_indicators], axis=1)
    .dropna(subset=ai_not_interested_indicators.columns, how="any")
)

ai_users_not_int["job_category"]
↪ = ai_users_not_int["DevType"].replace(devtype_map) #1

not_interested_job_dfs = []

for col in ai_not_interested_indicators.columns:      #2
    option_df = (
        ai_users_not_int[ai_users_not_int[col] == 1]
        .dropna(subset="job_category")
        .groupby("job_category")
        .size()
        .reset_index(name="count")
        .assign(option=col)
    )
    not_interested_job_dfs.append(option_df)

not_interested_options_vs_jobs = pd.concat(not_interested_job_dfs, 
↪axis=0, ignore_index=True)

job_not_int_crosstab = (      #3
    pd.crosstab(index=not_interested_options_vs_jobs["option"],
                columns=not_interested_options_vs_jobs["job_category"],
                values=not_interested_options_vs_jobs["count"],
                aggfunc="sum",
                normalize="columns")
    .transpose()
)

fig, axis = create_heatmap(      #4
    job_not_int_crosstab.round(2),
    square=False
)

fig.suptitle("What do different job roles NOT want to use AI for?")

axis.set(xlabel=None, ylabel=None)

plt.show()

1 这个职位角色的映射是在第六章中定义的。

2 将 AI“反”用例与职位类别交叉引用

3 转换为交叉表

4 最后,以热力图的形式可视化

figure

图 7.15 比较不同职位角色不感兴趣的 AI 用例的热力图

这个最终热力图揭示了对 AI 用于人类协作、项目规划和部署以及监控的抵触情绪在各个职位角色中是恒定的。有一些差异,例如,营销专业人士将测试代码评为所有职位角色中最不愿意使用 AI 的。

在总结我们的结果之前,让我们总结我们的分析过程,可视化我们追求的四个并行线索,以得到对我们所有利益相关者问题的最小可行答案。图 7.16 显示了此图。

figure

图 7.16 整个分析过程的总结

现在,是时候总结我们发现了什么,并决定向我们的利益相关者传达什么信息。

7.2.4 项目结果

我们不如专注于与我们最初问题直接相关的研究发现。我们的利益相关者的初步假设是

  • 新手和经验丰富的程序员使用这些工具的方式不同。

  • 人们对于当前 AI 工具的有用性和可信度的看法取决于他们的经验、职位角色以及他们具体使用工具的方式。

为了回答这些观点,在我们的分析中,我们发现

  • 编写和调试代码是最受欢迎的 AI 用例,但不同职位角色目前使用 AI 工具的方式存在不可否认的差异。

  • 很明显,人们对 AI 的一些用例不太感兴趣。

  • 现有的 AI 工具用户似乎比有志于使用这些工具的用户更倾向于信任这些工具。

  • 几乎没有证据表明开发者的经验年数会影响对 AI 工具的信任。

在市场缺口方面,我们能推荐哪些可以利用的建议?我们看到,积极使用 AI 工具的人比潜在用户更有可能对他们持积极看法,因此营销这些工具的好处至关重要。市场已经饱和了 AI 驱动的编码工具,因此我们应该专注于那些流行但服务不足的用例。这包括了解代码库、记录现有代码和测试。我们也看到了哪些缺口不存在;似乎没有必要追求 AI 驱动的协作或项目管理工具。

这些发现对于初步的利益相关者审查已经足够,但在数据中还有许多途径可以进一步探索 AI 驱动的发现/文档工具的可行性,例如

  • 有一个问题,那就是人们在他们的业务中多久能找到信息。我们可以将回答“经常”的人与当前和未来的用例进行交叉参考,以衡量对这种工具的兴趣。

  • 人们花费多长时间搜索信息也是一个问题。同样,这可以与关于 AI 工具和用例的答案进行交叉参考。

  • 最后,还有一个问题,那就是人们多久需要从他们的团队获得帮助,这同样可以作为一个探索人们如何对 AI 辅助产生兴趣的方法。

在我们的分析过程中,我们也遇到了一些值得与我们的利益相关者沟通的数据局限性。数据严重偏向于开发者,特别是那些活跃在 Stack Overflow 上的开发者。甚至数据集的 README 文件也指出,“Stack Overflow 上高度活跃的用户更有可能注意到调查的链接并点击开始。”这并不一定是一个坏的选择偏差案例,因为我们的初创公司的目标受众与 Stack Overflow 上的活跃用户重叠。我们缺少那些不活跃在线的开发者的存在,量化这个群体并调查其他捕捉他们的痛点和方法是值得的。

我们也没有访问到一些自由文本的回答,这些回答本可以让我们更深入地了解人们如何使用 AI 工具以及他们希望如何使用 AI 工具。创建我们自己的定性调查或访谈可以帮助我们更好地了解我们的目标市场,但与使用现有的调查相比,这将产生显著的成本。

活动:使用这些数据进一步的项目想法

本章中使用的调查数据有更多维度可以探索。与所有项目一样,我建议花时间考虑可以回答的不同研究问题。以下是一些开始的想法:

  • 开发者使用的工具和他们对 AI 的看法及使用之间是否存在关联?在项目中我们没有明确探讨的编程语言和其他相关工具的调查问题。

  • 关于开发者目前所在行业的提问,这为跨行业比较结果提供了机会。是否有些行业在人工智能方面比其他行业更具前瞻性?

  • 在调查开始时,有关于开发者如何喜欢学习的问题。这些问题的答案是否与对人工智能的不同立场相关?

值得强调的是,我们只是触及了调查数据中可用的信息的表面,但我们的以结果为导向的方法意味着我们只探索了与当前问题直接相关的途径。这就是从最终结果出发并有一个具体目标去努力的价值。

7.3 关于分类数据的总结性思考

在实际业务场景中,分类数据的普遍性不应被低估。任何数据录入由多选选项或下拉菜单组成、客户调查具有离散选项或包含国家、地区或部门等层次结构的数据都将生成分类数据。了解如何正确处理这些数据的相关选项至关重要,尤其是在基础培训中,通常很少有时间投入到这个主题上。在尝试弥合培训与现实世界之间的差距时,了解如何具体处理分类数据,例如了解一热编码等方法,将大有裨益。

最后,让我们看看本章涵盖的、对于处理分类数据所必需的技能。

7.3.1 适用于任何项目的处理分类数据的技能

在本章中,我们必须改变我们的方法,因为我们处理的是包含许多分类变量的调查数据。处理分类数据所需的具体技能,这些技能可用于任何涉及调查数据和分类值的问题,包括

  • 理解在调查背景下数据缺失的原因(例如,人们决定省略某些答案)

  • 确定顺序变量的自然顺序,例如李克特量表上的调查答案

  • 使用交叉表来交叉引用两个分类变量,并可选地使用热图进行可视化

  • 将多个答案拆分到单独的列中(例如,对于允许多选的多个选择题)

  • 通过一热编码将分类数据转换为二元指示变量,以便在分析中使用

  • 将数据从宽格式转换为长格式,以便更容易进行交叉制表

  • 使用 AI 工具,如 ChatGPT,来持续改进我们的工作方式

  • 通过合并相似类别等操作来调整现有类别,以使我们的分析更加清晰

  • 选择何时优先考虑分类数据而非连续数据,例如将连续的“年龄”列划分成更少、更宽泛的类别

  • 根据我们的数据类型选择合适的统计检验方法

摘要

  • 如适当的统计测试等高级方法可以帮助我们回答有关分类数据的更详细的问题。

  • 将连续数据分箱为分类数据可以增强我们的分析。

  • 选择合适的可视化方式来展示分类数据对于调查我们的数据和传达我们的发现至关重要。

  • 当处理调查数据时,了解调查问题如何映射到我们的数据集的列中是很重要的。

第八章:8 时间序列数据:数据准备

本章涵盖

  • 准备时间序列数据进行分析

  • 确定使用哪些时间序列数据子集

  • 通过处理缺失值和间隙来清理时间序列数据

  • 分析时间序列数据中的模式

你会遇到的大多数数据集都有一个时间组件。如果生成数据的过程涉及到在重复的时间间隔内进行相同的测量,那么这些数据被称为时间序列数据。例如,测量一个国家的年度 GDP 或生产线上的机器输出。然而,即使是看似静态的东西,如客户数据库,如果我们查看客户记录创建的日期,它也有一个时间组件。我们可能不会明确地将数据视为时间序列,但使用这个时间组件可以让我们在数据中获得额外的洞察。例如,你可以分析新客户记录创建的速度或你的运营团队在一天中的哪些时间将数据输入到数据库中。

真实业务案例:预测

第八章和第九章涵盖的项目灵感来源于我参与过的多个预测项目,以及大多数数据分析课程在处理时间数据主题上投入的时间相对较少的事实。

在 2020 年底,我不得不在最初的 COVID 封锁限制在英国解除后,提供对二手车市场走向的预测。准确预测整个市场已经足够困难,但封锁期间数据不完整以及必须预测一个没有人遇到过的情况,使得这个项目尤其困难。

最后,我们通过结合基本预测原则、对大流行的具体假设以及专家的领域知识,得出了预测。这是一个技术技能不足以解决业务问题的项目的良好例子。

与时间序列数据工作不仅仅是知道如何处理日期格式。它涉及到从数据中提取时间相关的组件,处理不同分辨率的时间数据,处理数据缺失,预测未来,以及确定数据是否可以进行预测。

在现实世界中,知道如何从你的数据中提取时间模式是一项至关重要的技能,我们将在本章的项目中通过实践来掌握这项技能。

8.1 与时间序列数据工作

时间序列是在不同、理想情况下均匀的时间间隔内进行的重复测量。典型的表格数据集,如客户数据集,将包含每名客户一行,每列将代表客户的不同属性,例如年龄、就业状态、地址等。然而,时间序列通常包含较少的列:一列代表测量的日期,一列或多列代表该时间点的单个测量值。因此,每一行代表相同的测量,而测量时间是使每一行独特的原因。

8.1.1 时间序列数据的潜在深度

让我们以一个简单的例子来解释——随时间推移的客户满意度。想象一下在机场、超市结账处或其他公共场所可以找到的那种基于笑脸的调查。当客户经过时,他们可以按下一个笑脸来表示他们的满意度水平。他们看到笑脸,数据库记录了一个从 1 到 5 的 Likert 量表上的简单值,以衡量从最不满意到最满意的值。表 8.1 显示了此数据集可能的样子。

表 8.1 时间序列数据集的一个示例
日期 满意度得分
2023-11-01 11:03:55 4
2023-11-01 11:17:02 5
2023-11-01 13:41:11 3
2023-11-01 14:06:43 4

要捕捉随时间推移的满意度,你只需要一个时间戳和满意度值。如果你在多个地点设置了这样的系统,你也可能找到一个位置 ID 列,但数据不会比这更复杂。

对于乍一看非常简单的数据集,我们可以进行什么样的分析?我们可以

  • 使用行簇作为代理来识别繁忙时期

  • 计算不同粒度(每日、每周、每月等)上的平均满意度

  • 调查数据中的趋势和季节性模式(如果客户在不同时间点或不同星期的不同日子满意度更高)

  • 找出满意度意外上升或下降的异常值

  • 将此与其他数据交叉引用以确定满意度与外部因素(如特殊事件)的关系

  • 比较不同地点的满意度得分

事实上,大多数这些问题只需要两到三列数据就可以回答,这显示了时间序列数据所具有的潜在深度。

8.1.2 如何处理时间序列数据

与时间序列一起工作意味着什么?在探索时间数据时,我们关心的事情与探索表格数据时关心的事情有很多相同之处。我们想要理解每一列,确保数据类型一致,并检查缺失值。然而,对于时间数据也有一些特定的考虑:

  • 时间序列的粒度是多少?它是一致的吗?

  • 时间序列中是否存在任何间隔?这些间隔是故意设置的吗?

  • 数据中是否存在趋势?

  • 存在季节性模式吗?

  • 是否有任何值得调查的异常值?

一旦您已经探索了您的时间序列数据集并想要进行预测,还有其他一些考虑因素:

  • 预测的正确粒度是什么?这将在一定程度上取决于数据中的噪声程度。小时数据可能太嘈杂,尽管日平均数据可能更平滑且更容易预测,但在日级别可能没有足够的数据。

  • 时间序列是否包含自相关:过去的值是否影响未来的值?对此有统计测试,时间序列模型将利用这一特性。

  • 时间序列是否平稳?一些时间序列模型要求数据平稳,这意味着在时间上大致保持恒定的均值和方差。在现实中,许多时间序列都有一些趋势和季节性,因此我们可能需要直接处理这些数据,或者使用为我们处理这些数据的预测模型。

  • 是否有任何异常值可以由外部因素解释?例如,您的销售数据中的某些峰值是否是由于一次性特别促销日,如黑色星期五?这些外部因素,技术上称为“外生变量”,可以用于许多预测模型以改善预测。

关于预测的最后一句话:你应该问的最重要的问题是,“预测将如何被使用?”这需要回答这些问题:

  • 预测需要多频繁?

  • 预测所需的粒度是多少?

  • 预测应该延伸多远?

  • 一个可接受的准确度水平看起来是什么样子?

  • 准确预测在商业意义上的价值是什么?因此,额外工作以改善现有预测的投资回报率是多少?

  • 预测模型所需的数据能否及时可用?

这些问题的答案至少与技术考虑一样,将指导您的分析决策。在完成这个项目时,就像任何项目一样,您应该始终关注您试图改善的商业结果。

8.2 项目 6:分析时间序列以改善自行车基础设施

让我们看看我们将分析道路交通数据以了解自行车基础设施应如何改进的项目。在本章中,我们将探索可用数据并为其分析做准备,分析将在第九章进行。

数据已提供,您可以在davidasboth.com/book-code自行尝试项目。您将找到可用于项目的数据,以及以 Jupyter 笔记本形式的示例解决方案。

这个项目完全是关于使用时间序列数据来寻找我们商业问题的答案。像往常一样,我们将从查看问题陈述、数据字典、我们希望达到的输出以及我们需要解决这个问题的工具开始。然后,我们将使用以结果为导向的框架制定我们的行动计划,然后再深入研究示例解决方案。

8.2.1 问题陈述

您被聘请参与一项新的政府倡议“Bikes4Britain”,该倡议旨在改善英国自行车基础设施。项目的第一阶段目标是确定全国最适合改善自行车基础设施的地方。具体来说,您的利益相关者正在寻找具有大量现有或增加自行车交通的地方的建议。

他们希望从公开数据源开始,并已将交通部道路交通统计数据(roadtraffic.dft.gov.uk)作为衡量全国自行车交通的一种方式。这是我们将在本项目中寻找模式和提出建议所使用的数据集。

备注 数据最初来自 roadtraffic.dft.gov.uk/downloads。感谢交通部在开放政府许可下提供这些数据。

我们将从部门的统计数据中使用的原始数据是原始计数数据。这是记录在特定计数位置在不同时间通过的车辆原始计数的记录。一些数据集过于高级,例如区域层面的年度汇总,而另一些则是估计值,例如估计的年度平均日流量数据(AADFs)。原始计数数据集包含最细粒度的数据,如果需要,我们总是可以将其汇总到更高的级别(例如,年度值)。

8.2.2 数据字典

在我们进一步思考项目之前,我们应该看看我们有哪些可用数据。数据字典文档(mng.bz/4ajw)包含在项目文件中,表 8.2 详细显示了列。数据字典按原样显示,未对原始内容进行修改。

表 8.2 数据字典,显示所有列定义
列名称 定义
Count_point_id 将 AADFs 与道路网络连接的道路连接的唯一参考
Direction_of_travel 行驶方向
Year 从 2000 年开始显示每年的计数
Count_date 实际计数发生的日期
Hour 计数发生的时间,其中 7 代表早上 7 点到 8 点,17 代表下午 5 点到 6 点
Region_id 网站区域标识符
Region_name 计数点(CP)所在的区域名称
Region_ons_code 该区域的英国国家统计办公室代码标识符
Local_authority_id 网站当地政府标识符
Local_authority_name CP 所在的当地政府机构
Local_authority_code 英国国家统计办公室为当地政府机构提供的代码标识符
Road_name 道路名称(例如,M25 或 A3)
Road_category 道路类型的分类(请参阅数据定义以获取完整列表)
Road_type 道路是主要道路还是次要道路
Start_junction_road_name 链接起始交叉路口的道路名称
End_junction_road_name 链接末端交叉路口的道路名称
Easting CP 位置的东经坐标
Northing CP 位置的北纬坐标
Latitude CP 位置的纬度
Longitude CP 位置的经度
Link_length_km 该 CP 网络道路链接的总长度(千米)
Link_length_miles 该 CP 网络道路链接的总长度(英里)
Pedal_cycles 自行车计数
Two_wheeled_motor_vehicles 两轮机动车计数
Cars_and_taxis 汽车和出租车计数
Buses_and_coaches 公共汽车和长途汽车计数
LGVs 大型货车计数
HGVs_2_rigid_axle 两轴刚性轴大型货车计数
HGVs_3_rigid_axle 三轴刚性轴大型货车计数
HGVs_4_or_more_rigid_axle 四轴或更多刚性轴大型货车计数
HGVs_3_or_4_articulated_axle 三或四轴铰接轴大型货车计数
HGVs_5_articulated_axle 五轴铰接轴大型货车计数
HGVs_6_articulated_axle 六轴铰接轴大型货车计数
All_HGVs 所有大型货车计数
All_motor_vehicles 所有机动车计数

数据字典显示,我们有关于车辆计数记录的日期和时间的记录。有一列专门记录自行车的计数,以及关于计数发生的道路段落的丰富信息。我们可以看到,由于我们满足时间序列的定义,即具有日期和时间信息以及在不同时间间隔进行的相同测量,因此我们将能够将不同位置的车辆计数视为单独的时间序列。

8.2.3 预期成果

项目输出是对进一步分析应集中关注哪个区域或哪些区域的推荐。这些可能已经是自行车交通量很大的区域,或者可能是自行车正在兴起或预计未来将有高自行车需求的区域。我们的推荐可能包含我们建议可以纳入以继续分析的其他数据集。在开始任何基础设施工作之前,我们可能还希望了解这些区域的一些更多信息,并且我们应该向我们的利益相关者概述这项额外的工作。

由于本项目跨越多个章节,本章(数据准备部分)的预期成果是对原始数据进行筛选和清洗后的版本,以便进行分析。第九章的成果将是分析结果和最终建议。

8.2.4 需要的工具

与大多数项目一样,你的数据分析工具需要能够读取、探索和可视化数据,以便适合项目。在示例解决方案中,我使用 Python 以及pandasmatplotlib库分别进行数据探索和可视化。在下一章中,我还介绍了statsmodels库的一些时间序列函数,用于调查数据的时间特定方面,以及pmdarima模块用于自动选择最佳的预测模型。对于这个项目,你的工具应该能够

  • 从包含数百万行的 CSV 或 Excel 文件中加载数据集

  • 执行基本的数据操作任务,例如过滤、排序、分组和重塑数据

  • 创建数据可视化

  • 进行统计分析,特别是时间序列数据的分析

  • 可选地基于时间序列数据进行预测

8.3 将结果驱动方法应用于分析道路交通数据

现在我们来看看我们将如何以结果驱动的方式解决这个问题并制定我们的行动计划。我们将遵循结果驱动过程的步骤,考虑到利益相关者的请求和感兴趣的区域来探索数据。

figure

我们是否完全理解了问题陈述?我们的利益相关者对看到自行车数量随时间和不同区域的变化感兴趣。我们知道这是我们将会关注的数据部分。然而,他们的请求并不像我们可能希望的那样具体。

我们需要定义关键术语,例如,一个地方适合升级自行车基础设施意味着什么。是已经有大量自行车手的地方吗?或者我们想要找到潜在的自行车交通量较低但随时间自行车增长最快的地方?在这种情况下,“增长”的标准是什么?如果我们能预测时间序列,我们甚至可能基于预测的未来交通量提出建议。

figure

现在我们来思考最终结果。我们必须关注自行车交通的模式,因此对于我们的分析,数据中的一些部分我们可以很大程度上忽略。知道我们对数据的特定方面感兴趣,将有助于我们在开始时确定在标准探索步骤之后下一步该去哪里。

figure

这是决定使用原始计数数据而不是其他可用数据集的步骤。这直接由问题陈述驱动。区域层面的年度总结过于笼统,无法揭示自行车交通情况,而使用估计的测量值会降低我们发现的有用性,使我们只剩下原始计数数据来分析。在这种情况下,我们不需要就下载哪个数据集做出进一步的决定,因为整个原始数据集作为一个文件提供。

figure

就像大多数项目一样,数据已经被下载了,但我们没有对其进行任何其他更改,以最好地模拟首次探索它的体验。在现实世界中,获取数据可能是一个令人惊讶的障碍,尤其是如果你需要许可,并且存在隐私和治理问题。这里的情况并非如此,因为我们正在处理开放政府数据。

figure

让我们现在考虑我们分析中将要采取的步骤。在我们转向建议部分之前,我们需要彻底探索数据集。具体来说,我们想要

  • 调查我们数据粒度 — 一行数据代表什么?是一天一个地点的数据,还是其他什么?数据的粒度是首先要调查的事情之一,因为它会影响到所有其他的数据转换,比如聚合。

  • 了解数据的地理和时间覆盖范围 — 例如,因为数据集不是一个单一的时间序列,而是多个,我们需要知道每个可用的地点是否有相同数量的数据。

  • 识别时间序列中的差距 — 每个地点都有恒定间隔的测量值吗?这很重要,以确保我们在每个地点都有足够的样本,并且对于预测也是一项关键要求。大多数预测算法都不适用于数据中的差距或不一致的间隔。

  • 调查自行车计数分布 — 一行数据中典型的自行车流量是多少?了解这一点将立即帮助我们识别出自行车交通流量最高的地方。

  • 观察时间模式 — 这包括观察自行车交通在不同时间、不同星期日和多年间的波动情况。我们能识别出季节性模式吗?哪些地点显示出自行车交通增长的趋势?

  • 缩小搜索范围 — 这意味着由于存在差距,我们可能无法以相同程度详细分析每个地点。我们可能必须将数据过滤到在更长时间范围内有更完整记录的地点,特别是如果我们对寻找时间模式和预测感兴趣的话。

*figure

这个项目的输出很可能是线形图和对话的结合。线形图是事实上的时间序列可视化工具,因为它们最能代表分析结果的时间成分。我们可能还会创建其他可视化,但这是一个第一个迭代就会与我们的利益相关者引发很多讨论的项目。当我们确定可用数据的局限性时,我们将能够就其他数据集提出建议,并且决定关注哪些数据集将与我们的领域专家合作完成。

figure

由于数据集专注于交通量,我们将在我们的初步建议之后探索多个角度。在示例解决方案中,我们将探索一些可能的未来迭代方向。

8.4 一个示例解决方案:应该在哪里集中改善骑行基础设施?

现在,是时候查看分析这些数据的一个示例流程了。像往常一样,我强烈建议您首先尝试自己完成这个项目。如果您有自己的分析来与之比较,示例解决方案将更有意义。重复一遍,这个解决方案不是唯一的解决方案,只是您在过程中可能做出的决策和可能得出的结论的一系列。使用它来产生更多想法,并从不同的角度看待您如何处理同一个项目概述。

明确我们的最终目标,并思考我们想要采取的各种步骤后,我们的行动计划将从调查数据开始。只有在这种情况下,我们才能理解我们可以用现有资源回答哪些具体问题。然后,我们可以专注于寻找骑行行为中的模式和趋势,甚至尝试预测未来的骑行趋势。

8.4.1 调查可用数据并提取时间序列

与任何数据问题一样,我们的第一步是查看数据本身。我们知道预期哪些列,但查看一些样本行将帮助我们理解。我们将导入必要的库并检查一些数据行。输出显示在图 8.1 中:

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

traffic = pd.read_csv("./data/dft_traffic_counts_raw_counts.csv.gz")
print(traffic.shape)
traffic.head()

figure

图 8.1 交通计数数据的前几行的一瞥

数据的形状是 (4815504, 35),这意味着我们有 35 列和接近 500 万的观测值。从列中,我们可以看出这不是一个单一的时间序列。实际上,它是由多个时间序列组成的,这些时间序列位于不同的“计数点”,即测量位置。每个计数点的测量可以被视为一个单独的时间序列,但我们也有按地区、地方当局或甚至不同时间段进行聚合的选项。从我们的数据字典中,我们还可以了解到我们将对Pedal_cycles列感兴趣,该列测量在测量期间观察到的自行车数量。

调查时间序列的完整性

让我们检查数据以了解其完整性。首先,我们将查看缺失数据。以下代码生成了图 8.2 中的输出:

traffic.isnull().sum()

figure

图 8.2 交通数据中每列的缺失值

看起来记录大多是完整的,只是缺少了大量的道路名称和长度。这可能和不同的道路类型有关,因为并非该国所有的道路,以及因此数据集中的道路,都必须有名称。我们将保留这些缺失,因为我们没有理由相信这些行是错误的。有一些测量数据缺失,我们将假设可以用以下代码中的零来填充:

measurement_cols = [
    'Pedal_cycles', 'Two_wheeled_motor_vehicles',
    'Cars_and_taxis', 'Buses_and_coaches',
    'LGVs', 'HGVs_2_rigid_axle', 'HGVs_3_rigid_axle',
    'HGVs_4_or_more_rigid_axle', 'HGVs_3_or_4_articulated_axle',
    'HGVs_5_articulated_axle', 'HGVs_6_articulated_axle',
    'All_HGVs', 'All_motor_vehicles'
]

for col in measurement_cols:
    traffic[col] = traffic[col].fillna(0)

为了完整性,我们还可以通过检查 Region_name 列来调查数据覆盖的区域。以下代码生成了图 8.3 中的输出:

traffic["Region_name"].value_counts()

figure

图 8.3 交通数据中区域的分布

看起来数据覆盖了英格兰、苏格兰和威尔士,以及英格兰的各种地区。在我们继续调查之前,让我们开始构建图表来记录分析。图 8.4 显示了第一步,其中我们必须对缺失值做出决定。

figure

图 8.4 分析的第一步

我们接下来的问题是关于粒度:一行数据究竟代表什么?

调查时间序列粒度

建立我们数据中的一行代表什么非常重要。它是在特定时间和地点的测量,但是什么具体的列组合使一行独特?我们可以通过计算我们认为独特的列组合的行数来测试这一点,并验证它是否与整个数据集的行数相匹配。

关于复合主键的注意事项

当多个列使记录独特时,这被称为 复合主键。这是唯一性不是来自单个 ID 列,而是来自多个列的组合。例如,如果多个客户数据库合并,客户 ID 可能不是唯一的。在这种情况下,客户 ID 和源数据库名称可能就是使记录独特的东西。

这又是基础训练与现实世界不同的另一种方式。在现实中,数据库通常具有复杂的结构,包括复合主键。

在这种情况下,唯一键至少必须包含 Count_point_idCount_date 以及因此也包含的 Year。还有一个 hour 列,表明数据是按小时粒度。如果我们假设这些列是复合键,我们可以计算唯一的组合并验证它们是否与行数相匹配:

len(traffic[["Count_point_id", "Year", "Count_date",
↪ "hour"]].drop_duplicates())

这给我们带来了 2435120,行数太少,这表明我们还没有考虑到另一列。这个数字大约是数据的一半,这表明我们正在寻找的列通常有两个值,因此在每个地点每小时,也有另一种测量方式。查看列,这可能就是 Direction_of_travel,意味着在每个地点的交通流量分别按两个方向计数。让我们将那一列添加到键中,看看它是否与行数匹配:

len(traffic[["Count_point_id", "Year", "Count_date",
↪ "hour", "Direction_of_travel"]].drop_duplicates())

这返回 4815480,这个数字与行数非常接近,这表明我们已经找到了正确的列组合,但数据中包含重复项。让我们调查这些重复项。以下代码找到重复键并生成图 8.5 中的输出:

duplicate_groups = (
    traffic
    .groupby(["Count_point_id", "Year", "Count_date",
↪ "hour", "Direction_of_travel"])
    .size()
    .loc[lambda x: x > 1]
)

duplicate_groups

figure

图 8.5 具有相同复合主键的重复记录

看起来有两个地点在两个日期上有重复的测量值。我们想知道这些测量值是否也重复,或者行是否是完全重复的。我们通过以一个键为例,查看重复行在哪些值上有所不同来完成这项工作。以下代码执行此操作,并在图 8.6 中产生输出:

example_dupes = (
  traffic[
    (traffic["Count_point_id"] == 7845)      #1
      & (traffic["Count_date"] == "2014-09-03 00:00:00")
      & (traffic["hour"] == 7)
      & (traffic["Direction_of_travel"] == "W")
  ]
)

(
  example_dupes
  .eq(example_dupes.shift(-1))   #2
  .iloc[0]
  .loc[lambda x: x == False]
)

1 找到重复的一个特定例子

2 使用位移法检查两行中的值是否相等

figure

图 8.6 示例重复行中值不匹配的列

这告诉我们,对于那个地点和日期,不同的列是测量通过车辆数量的列。让我们查看具体的测量值,以确定它们有多大的不同。以下代码提取了这些列。返回的数据包含许多列,默认情况下它们并没有全部显示。即使我们能够显示所有列,这也是可能的,但它们在屏幕上无法水平显示,需要滚动。一个技巧是取一行或两行数据,并将其转置以显示为只有一或两列。我们在这里这样做,输出显示在图 8.7 中:

(
  example_dupes[[
    'Two_wheeled_motor_vehicles', 'Cars_and_taxis', 'Buses_and_coaches',
    'LGVs', 'HGVs_2_rigid_axle', 'HGVs_3_rigid_axle',
    'HGVs_4_or_more_rigid_axle', 'HGVs_3_or_4_articulated_axle',
    'HGVs_5_articulated_axle', 'HGVs_6_articulated_axle', 'All_HGVs',
    'All_motor_vehicles']]
  .transpose()
)

figure

图 8.7 相同记录的并列比较

对于相同的位置和测量日期时间的组合,值相当不同,因此我们现在面临一个决策。处理这些重复项时我们有哪些选择?

  • 我们是否应该以某种方式合并值?如果这些是部分测量值,这将是合理的,但我们没有这方面的证据,而且数字太相似了。

  • 其中一个是新数据,使得另一行变得过时,在这种情况下我们应该删除第一行吗?这是可能的,但如果是这样,我们除了假设出现较晚的那个更近之外,没有其他方法知道哪个是新的测量值。这感觉像是一个没有证据的强烈假设。

  • 我们可以完全删除这些行,但这会在我们的时间序列中引入一个缺口,这对于时间序列分析是问题。

  • 我们可以在两个记录之间平均计数。这保留了时间序列,并使我们的数字保持在正确的范围内,但本质上我们是以这种方式制造数据。

这里没有正确答案。每个选择都有自己的假设和后果。我们将倾向于保留时间序列,采用平均方法。虽然这确实补全了未实际记录的测量值,但这些重复值占整体数据的比例很小,不会造成大问题。

为了合并这些重复项,我们可以通过复合键对数据进行分组,为每个唯一标识符创建一个组,并平均测量行。在大多数情况下,由于键是唯一的,我们将平均一行,使其不受影响。唯一的额外技巧是处理缺失列。我们的道路名称和链接长度列,作为复合键的一部分,包含缺失值。特别是pandas库在分组列缺失时无法正确分组记录。

为了避免这种情况,我们将暂时用占位符填充缺失值,进行去重,然后删除占位符值。对于 Road_name 列,我们可以使用文本“占位符”,但对于数值列,我们需要找到一个在数据中尚未出现过的值。负数在这里效果很好,但我们应该检查是否有任何原因导致链接长度的负值:

print(traffic["Link_length_km"].min(),
      traffic["Link_length_miles"].min())

输出分别是 0.1 和 0.06,这告诉我们没有负值,我们可以使用其中一个作为占位符。因此,去重的流程如下:

  • 用占位符替换缺失值。

  • 按除测量值外的所有列进行分组。

  • 在每个组内,大多数情况下每行只有一个,计算测量值的平均值。

  • 在分组和汇总的数据集中,再次用缺失数据替换占位符。

以下代码执行此操作并验证我们已经将行数减少到唯一组的数量:

TEXT_PLACEHOLDER = "PLACEHOLDER"
NUMBER_PLACEHOLDER = -9999

group_cols = [
  'Count_point_id', 'Direction_of_travel', 'Year', 'Count_date', 'hour',
  'Region_id', 'Region_name', 'Region_ons_code', 'Local_authority_id',
  'Local_authority_name', 'Local_authority_code', 'Road_name',
  'Road_category', 'Road_type', 'Start_junction_road_name',
  'End_junction_road_name', 'Easting', 'Northing', 'Latitude',
  'Longitude', 'Link_length_km', 'Link_length_miles'
]

traffic_deduped = (
  traffic
  .assign(
    Start_junction_road_name = lambda df_:
↪ df_["Start_junction_road_name"].fillna(TEXT_PLACEHOLDER),
    End_junction_road_name = lambda df_:
↪ df_["End_junction_road_name"].fillna(TEXT_PLACEHOLDER),
    Link_length_km = lambda df_:
↪ df_["Link_length_km"].fillna(NUMBER_PLACEHOLDER),
    Link_length_miles = lambda df_:
↪ df_["Link_length_miles"].fillna(NUMBER_PLACEHOLDER)
  )
  .groupby(group_cols)
  .mean(numeric_only=True)
  .reset_index()
  .assign(
    Start_junction_road_name = lambda df_:
↪ df_["Start_junction_road_name"].replace(TEXT_PLACEHOLDER, np.nan),
    End_junction_road_name = lambda df_:
↪ df_["End_junction_road_name"].replace(TEXT_PLACEHOLDER, np.nan),
    Link_length_km = lambda df_:
↪ df_["Link_length_km"].replace(NUMBER_PLACEHOLDER, np.nan),
    Link_length_miles = lambda df_:
↪ df_["Link_length_miles"].replace(NUMBER_PLACEHOLDER, np.nan)
  )
)

print(traffic.shape, traffic_deduped.shape)

输出是 (4815504, 35) (4815480, 35),其中第二对值显示我们已经将数据减少到每个复合主键唯一组合的一行。这感觉像是为了去除几个重复项而做了很多工作,但重复项的存在可能会在分析中引起多个问题,因此最好解决它们。

图 8.8 显示了我们的最新流程版本,包括我们刚刚采取的合并重复记录的步骤。

figure

图 8.8 我们分析两步的示意图

到目前为止,我们已经调查并处理了缺失数据,并确保我们理解了其粒度。现在,是时候查看覆盖范围了。

调查时间序列覆盖范围

当我说我们要查看覆盖范围时,在这个例子中,我的意思是数据中值的日期范围。我们简要地查看过地理覆盖范围,现在我们想要调查以下内容:

  • 数据的日期范围一般是怎样的?

  • 日期范围在较小的时序(例如,每个位置)中是否会有所变化?

  • 数据中是否存在一致的测量间隔?

  • 任何时间序列中是否存在缺失?

这些问题的答案将不仅决定我们最终分析的质量,还决定我们是否需要仅仅因为全国数据覆盖不统一而专注于国家的某些部分。首先,让我们在将Count_date列转换为正确类型后,了解数据的日期范围。输出如图 8.9 所示:

traffic["Count_date"] =
↪ pd.to_datetime(traffic["Count_date"], format="%Y-%m-%d %H:%M:%S")
traffic["Count_date"].agg(["min", "max"])

figure

图 8.9 整个数据集的日期范围

代码的输出显示,数据集中遇到的第一天是 2000 年 3 月,最后一天是 2022 年 11 月。我们有 20 年的覆盖范围,尽管还需要看这是否在测量地点上是一致的。我们想知道

  • 每个位置都有 20 年的数据吗?

  • 不同位置的时间序列中是否存在任何空缺?

调查这个问题的一种方法是为每个位置计算第一个和最后一个日期,计算它们之间的差异,并研究这个差异数字的分布。这将让我们一眼就能知道每个位置特定时间序列的长度。以下代码实现了这一点,输出如图 8.10 所示:

coverage_by_point = (
    traffic
    .groupby("Count_point_id")
    ["Count_date"]
    .agg(["min", "max"])
    .assign(coverage_years = lambda x: (x["max"] - x["min"]).dt.days / 365)
    .sort_values("coverage_years", ascending=False)
)

coverage_by_point

figure

图 8.10 各位置覆盖范围(按年)

这个表格告诉我们一些重要的事情:

  • 覆盖范围在位置之间差异很大。 有些位置只有一天的测量数据,而有些位置则在整个 22 年期间都有数据。

  • 测量日期各不相同。 初始查看数据时,这并不明显,但结果发现,任何测量时间序列都没有一致的开始或结束点。

为了更好地了解这些值的分布,让我们创建一个直方图。以下代码生成了图 8.11 中的直方图:

fig, axis = plt.subplots()

coverage_by_point["coverage_years"].hist(bins=50, ax=axis)

axis.set(
    title="Distribution of coverage (years) by location",
    xlabel="Date range (number of years)",
    ylabel="Frequency"
)

plt.show()

figure

图 8.11 显示不同位置覆盖范围的直方图

看起来,位置绝大多数的覆盖范围接近于零。也就是说,大多数位置只有一天的测量数据。这带来一个问题,因为那些位置的数据并不构成一个真正的时间序列,除了单日的小时测量数据。这些数据不足以得出很多见解。再次,我们面临以下选择:

  • 我们是否包括只有一天测量数据的位置?这样做意味着我们不会失去很多覆盖范围,但我们也无法回答那些地区增长趋势的问题。

  • 我们是否只关注数据量充足的位置?这意味着我们将有更稳健的结果,但位置和区域将大大减少。

这是一个提醒我们框架的好决策点。我们希望以结果为导向,并将研究问题置于我们思维的优先位置。我们可能需要进一步的工作来跟进我们的建议,并且绝对不希望基于稀疏的数据做出任何基础设施决策。基于这一点,我们将努力减少数据量,只保留数据量最大、覆盖范围最高且没有空缺的时间序列。

注意:这是那些将极大地影响我们解决方案差异性的决策之一。如果你的结果与我的不符,不要假设是你犯了错误。我们可能只是做出了不同的决策,导致了不同的结果。只要这些决策及其关键假设得到记录,不同的结果可能同样有价值且有用。

每当我们调查缺失数据时,我们想知道是否存在任何模式在缺口中。是否有任何特定因素导致我们的一部分数据缺失?在这种情况下,我们处理的是低覆盖率而不是缺失数据,但这个想法是适用的。让我们调查是否有某些地区的覆盖率较低。这可能是为什么?

  • 一些地点可能比其他地点晚些时候被添加到“交通测量计划”中。

  • 在某些地点进行测量可能存在物流困难。

  • 新建住宅区周围已建成新道路,测量可能无法在更早的日期开始。

无论原因如何,我们想知道低覆盖率是否在全国范围内随机分布,或者是否存在我们应该注意的模式。让我们使用图 8.10 中的表格来关注只有一天数据的地点点。我们将删除相同位置 ID 的重复行,因为我们想了解它们的分布情况,而不是它们的细粒度测量。以下代码显示了按地区划分的只有一天覆盖率的地点数量,如图 8.12 所示:

zero_location_ids = coverage_by_point                 #1
↪ [coverage_by_point["coverage_years"] == 0].index  

zero_locations = (
    traffic[traffic["Count_point_id"].isin(zero_location_ids)]
    .drop_duplicates("Count_point_id")
)
print(len(zero_locations))
zero_locations["Region_name"].value_counts()

1 仅有一日测量数据的地点被称为“零”,因为首次和最后测量日期之间的差异为零。

figure

图 8.12 按地区划分的只有一天数据的地点数量

这里存在差异,但我们不能陷入使用绝对数字做出判断的陷阱。可能只是因为东南部比威尔士有更多的单日地点,因为地点点更多。让我们将这些计算为每个地区总地点数的百分比,以获得公平的比较。

首先,我们按地区计算位置点的数量,然后使用这个数量来计算图 8.12 中的数字作为百分比。以下代码按地区计算位置,如图 8.13 所示:

location_sizes = (
    traffic
    .groupby("Region_name")
    ["Count_point_id"]
    .nunique()
)

figure

图 8.13 按地区划分的计数点数量

以下代码将两个表合并在一起,并生成如图 8.14 所示的输出表:

(
    location_sizes
    .reset_index()
    .merge(
        zero_locations["Region_name"]
            .value_counts()
            .reset_index(name="count")
            .rename(columns={"index": "Region_name"}),
        on="Region_name"
    )
    .rename(columns={
        "Count_point_id": "total_points",
        "count": "number_of_zeros"
    })
    .assign(pct_zeros = lambda x: x["number_of_zeros"] / x["total_points"])
)

figure

图 8.14 每个地区的总地点数和单日地点数

如果单日地点真的存在仅限于某些地区的问题,我们会在表中发现相当大的变化。目前,只有威尔士和伦敦的单一日期数据的地点比例明显较低。我们可以对此进行更深入的调查,但本着尽快得到结果的精神,我们将假设我们对单日地点的存在感到满意,这是普遍发生的事情,而不是直接要解决的问题。

现在我们已经查看了缺失数据、粒度和覆盖范围,让我们将注意力转向缺口。在时间序列中,缺口是一个问题,因此我们希望将我们的数据减少到我们可以获得更长和完整时间序列的地点。

调查时间序列中的缺口

我们已经确定,不同的地点从不同的起点开始跟踪数据,并且持续时间不同。为了识别缺口,我们不能仅仅计算一个地点看到的唯一日期的数量;我们需要计算每个遇到的日期与之前遇到的日期之间的差异,并标记任何超过一天缺口的案例。

首先,让我们看看每个地点每个日期的数据点数量,以了解是否可能存在连续性问题。以下代码计算了这一点,并在图 8.15 中产生了表格:

points_and_dates = (
    traffic
    .groupby(["Count_point_id", "Count_date"])
    .size()
    .reset_index()
    .sort_values(["Count_point_id", "Count_date"])
)

points_and_dates.head()

figure

图 8.15 每个地点 ID 和日期的数据点数量

这个表格向我们展示了重要的一点。我们错误地假设了一年中每天都会进行测量。数据在粒度上是每天的,但每年只有一天的数据。我们之前没有正确地切割数据以找到这一点,但这一点为我们提供了一个更清晰的了解我们所拥有的。

要了解是否存在缺口,首先,我们可以检查每个地点的Year列中有多少唯一值。那些有 23 个的是在 2000 年至 2022 年(包括 2022 年)之间每年都有测量的地点。我们将只关注至少有 10 年数据的地点,但这有些随意。我们也可以限制最近 5 到 10 年内数据完整的时序。在这里,我们将选择完整性而不是最近性,以下代码计算了这一点,并在图 8.16 中输出了结果:

num_years_by_point = (
    traffic
    .groupby("Count_point_id")
    ["Year"]
    .nunique()
    .loc[lambda x: x > 10]
    .sort_values(ascending=False)
)

num_years_by_point

figure

图 8.16 每个地点的独特日历年分布

让我们也看看这些地点中的第一个地点的时间序列,以了解我们的数据中完整时间序列是什么样的。以下代码生成了图 8.17 中的图表:

fig, axis = plt.subplots()

LOCATION_ID = "26010"

(
    traffic
    .query(f"Count_point_id == {LOCATION_ID}")
    .groupby("Count_date")
    ["All_motor_vehicles"]
    .sum()
    .plot(ax=axis)
)

axis.set(
    title=f"Number of vehicles over time (location {LOCATION_ID})",
    xlabel="Date",
    ylabel="Number of vehicles (total)"
)

plt.show()

figure

图 8.17 位置 26010 的一个示例时间序列

这是每年计数当天在特定地点看到的车辆总数。已经有一些有趣的特点,例如由于 COVID-19 封锁导致的 2020 年的下降。

下一步是过滤我们的位置列表,只保留没有差距的时间序列。为此,我们将确保我们的数据已排序,并创建一个临时列来捕获前一年的年份,这样我们就可以找到行与前一行的差距超过一年的实例。以下代码添加了这些额外的列,新的gaps DataFrame 的快照显示在图 8.18 中:

long_count_points = num_years_by_point.index

gaps = (
    traffic
    .query("Count_point_id in @long_count_points")
    [["Count_point_id", "Year"]]
    .drop_duplicates()
    .sort_values(["Count_point_id", "Year"])
    .assign(
        prev_year= lambda x: x["Year"].shift(),
        diff= lambda x: x["Year"] - x["prev_year"]
    )
)

gaps.head()

figure

图 8.18 显示了新的gaps DataFrame 的快照,其中突出显示了一些重要的行

这些新列帮助我们识别之前遇到的测量数据来自一年以上的地方。在图 8.18 的高亮部分,我们可以注意到 2018 年位置 ID 501 没有测量数据,因此行之间的差距是两年。当遇到下一个位置 ID 时,如果前一个位置 ID 的最后一年晚于下一个位置 ID 的第一年,差距可能会变成负数。

为了识别差距,我们可以简单地过滤这个数据集,使得diff列大于 1。然而,我们可能会遇到边缘情况,即下一个位置 ID 恰好在前一个 ID 之后两年开始,我们会错误地将其标记为存在差距。

为了确保我们正确地过滤差距,我们还需要跟踪前一个列的位置 ID,这样当我们遇到超过一年的差距时,我们也可以检查位置 ID 是否仍然相同。以下代码执行此操作,并显示了图 8.19 中一些有问题的差距行:

gaps = (
    gaps
    .assign(
        prev_id= lambda x: x["Count_point_id"].shift()
    )
    .query("diff > 1 and Count_point_id == prev_id")
)

gaps.head()

figure

图 8.19 显示了时间序列中一些表示问题差距的行

我们现在可以使用这个gaps DataFrame 来找到我们想要从最终时间序列数据中排除的所有独特位置 ID。这是通过以下代码完成的。结果 DataFrame 的一部分显示在图 8.20 中:

gap_ids = gaps["Count_point_id"].unique()

all_time_series_raw = (
    traffic
    .query("Count_point_id in @long_count_points \
    and Count_point_id not in @gap_ids")
)

all_time_series_raw.head()

figure

图 8.20 显示了过滤后的交通数据行的快照

图 8.20 现在显示了从原始的原始traffic DataFrame 中过滤出的行。它只包含至少有 10 年连续无差距数据的位置 ID。现在让我们将此聚合为在位置级别汇总的时间序列,以便我们更好地了解我们剩下多少数据。以下代码执行此聚合,图 8.21 显示了新聚合数据的前几行:

all_time_series = (
    all_time_series_raw
    .groupby(["Count_point_id", "Count_date"])
    ["All_motor_vehicles"]
    .sum()
    .reset_index()
)

print(all_time_series["Count_point_id"].nunique())
all_time_series.head()

figure

图 8.21 显示了现在按年度时间序列聚合的过滤交通数据的快照

现在每一行对应一个位置 ID 和测量日期。如图 8.21 顶部所示,我们仍然有超过 1,400 个独特位置 ID 的数据。这些现在都是每年都有测量的时间序列,因此这是一个没有差距的时间序列。

要精确地说,我们有一个时序数据,显示了在特定年份某一天通过计数点的车辆总数。每年只有一天进行测量。这是一个重要的细节,因为它导致了一些注意事项:

  • 图 8.21 显示,每年的测量不是在同一天进行的。如果我们想调查交通模式随时间的变化,测量至少应该在每年的同一时间进行,否则我们可能会比较夏季交通与冬季交通,例如。一个选择是只保留每年在相同时间进行一致测量的时序数据。

  • 此后,无论我们从哪一年的哪个部分进行测量,都会引入偏差。对于只有冬季测量数据的地点,在当年那个时间骑车可能减少,因此骑车模式可能没有帮助。

  • 如果每年的日期都相同,这实际上可能是一个问题,因为我们可能会比较不同星期几的日期,甚至工作日与周末。

让我们检查最后一个观点。数据分布在星期几?以下代码调查了这一点,输出结果如图 8.22 所示:

(
    all_time_series_raw[["Count_date"]]
    .drop_duplicates()
    ["Count_date"]
    .dt.weekday
    .value_counts(normalize=True)
    .sort_index()
)

图

图 8.22 不同星期几的行百分比

这告诉我们,大约 20%的行分布在星期二到星期五,而星期一略少。因为我们是从星期一开始计算一周的天数,所以我们也知道剩余数据中没有周末,这样就缓解了一个担忧。

我们仍然可以使用这些数据作为交通随时间变化的代理,帮助我们聚焦于具有有趣骑车交通模式的地点,但我们必须充分了解局限性,尤其是在向利益相关者展示结果时。

在继续分析这些时序数据之前,我们的最后一步是看看如果我们只保留每年每月都进行测量的时序数据会发生什么。

找到每年在同一时间记录的时序数据

以下代码识别了每年仅在相同月份进行测量的位置点。图 8.23 显示了输出结果:

same_month_time_series = (
    all_time_series
    .assign(month=lambda x: x["Count_date"].dt.month)
    .groupby("Count_point_id")
    ["month"]
    .nunique()
    .loc[lambda x: x == 1]
)

print(len(same_month_time_series))

same_month_time_series.head()

图

图 8.23 仅遇到相同月份的位置 ID

保留这个过滤器将使我们的数据减半,但仍然留下不到 700 个时间序列。我们应该验证与这些位置 ID 相关的时间序列确实只包含相同的月份。我们将以第一个位置 ID 为例,但在现实中,我们可能想要抽查几个案例。以下代码检查了 ID 为 900056 的计数点的时序数据,输出结果如图 8.24 所示。

all_time_series[all_time_series["Count_point_id"] == 900056]

图

图 8.24 位置 ID 900056 的时序数据

图 8.24 显示,在 900056 位置对交通进行了 13 年的计数,总是在 5 月份进行。这使得测量结果年复一年更具可比性。现在,让我们将此数据导出到一个中间文件中,以将清理过程与分析过程分开。

仅导出完整的时间序列数据

导出数据的中间版本是一个好习惯,特别是如果你有大量的原始数据,并且清理和转换它需要一些时间。

我们希望将原始数据过滤到仅包含我们已识别的位置 ID。我们将使用 Parquet 格式,因为它紧凑且保留数据类型。此文件将是第九章分析的起点。以下代码创建了这个导出文件:

ids_to_export = same_month_time_series.index

(
    traffic
    .query("Count_point_id in @ids_to_export")
    .to_parquet("time_series.parquet.gz", compression="gzip")
)

8.4.2 到目前为止的项目进度

在我们进入第九章项目分析部分之前,让我们回顾一下本章我们所取得的成果,这是项目的数据准备部分。以下是关于我们数据的了解:

  • 一行代表在特定日期特定方向的单个位置上进行的测量。这些列的组合使记录独特。

  • 对于每个位置,我们都有一个给定日历年度内最多一天的唯一测量日。

  • 在不同的位置 ID 之间,测量年份的数量差异很大。这意味着我们的时间序列数据存在不一致的覆盖范围和许多缺口。

  • 除了大约一半的道路名称和长度数据缺失外,没有显著的缺失值。

为了减轻一些问题,我们只提取了最长和最完整的时间序列位置,以便在分析的第二部分中集中关注。图 8.25 显示了我们所采取的分析步骤和到目前为止所做的决定。

figure

图 8.25 我们步骤的最新图示,包括调查覆盖范围和处理缺口

此图展示了我们到目前为止的过程,我们将使用本章的输出,即原始交通数据的过滤版本,作为第九章分析的起点。

摘要

  • 时间序列数据可能看似简单,但其中包含复杂性和隐藏的价值。

  • 了解如何操作时间数据可以扩展你的数据分析工具集。

  • 可用时间序列的粒度决定了我们可以执行的分析。例如,无法从月度数据中确定每日模式。

  • 如果数据中没有缺口,时间序列分析效果最佳。

  • 如果存在差距,需要通过平滑处理或估计这些差距中的值来处理*。

第九章:9 时间序列数据:分析

本章涵盖

  • 分析时间序列数据以回答研究问题

  • 揭示看似简单的时间序列中的隐藏深度

  • 评估时间序列数据集是否适合进行预测

  • 检查时间序列的组成部分

  • 建立预测模型

在本章中,我们继续探讨时间序列数据的价值。在第八章中,我们探讨了原始时间序列数据,并在进一步分析时间序列之前决定保留哪些记录。本章是关于流程的第二部分:分析时间序列数据以寻找模式,以及分解和预测。我们首先回顾上一章的项目概述,并总结到目前为止已完成的工作,然后再继续分析。

9.1 项目 6 回顾:分析时间序列以改善自行车基础设施

我们已经准备好了数据,它已准备好进行分析。但在我们开始分析之前,让我们回顾一下上一章的问题陈述和数据字典。数据可在davidasboth.com/book-code上供您尝试项目使用。您将找到可用于项目的文件,以及以 Jupyter 笔记本形式的示例解决方案。本章的笔记本从第八章结束的地方继续。

9.1.1 问题陈述

您被雇佣参与一项新的政府倡议,Bikes4Britain,该倡议旨在改善英国的自行车基础设施。项目的第一阶段目标是确定全国最适合改善自行车基础设施的地方。具体来说,您的利益相关者正在寻找既有或正在增加自行车交通流量的地方的建议。他们希望从公开数据源开始,并已将交通部(主页为roadtraffic.dft.gov.uk)的公路交通统计数据作为衡量全国自行车交通量的方式。

注意:我们将在本项目中用于寻找模式和提出建议的数据集最初来源于roadtraffic.dft.gov.uk/downloads。感谢交通部在开放政府许可下提供这些数据。

我们从交通部统计数据中使用的数据是原始计数数据。这是记录在不同时间通过特定计数位置的车辆原始计数。一些数据集过于高级,例如区域层面的年度汇总,而另一些则是估计值,例如估计的年度平均每日流量数据(AADFs)。原始计数数据集包含最细粒度的数据,如果需要,我们总是可以将其聚合到更高的层次(例如年度值)。

9.1.2 数据字典

数据字典文档,最初从mng.bz/4ajw获取,包含在项目文件中,表 9.1 详细显示了列。

表 9.1 数据字典,显示所有列定义
列名称 定义
Count_point_id 将 AADFs 与道路网络连接的唯一参考
Direction_of_travel 行驶方向
Year 从 2000 年开始的每年计数
Count_date 实际计数发生的日期
Hour 计数发生的时间,其中 7 代表早上 7 点到 8 点,17 代表下午 5 点到 6 点
Region_id 网站区域标识符
Region_name CP 所在的区域名称
Region_ons_code 该区域的国家统计局代码标识符
Local_authority_id 网站地方当局标识符
Local_authority_name CP 所在的当地当局
Local_authority_code 国家统计局的地方当局代码标识符
Road_name 路名(例如,M25 或 A3)
Road_category 路类型的分类(请参阅数据定义以获取完整列表)
Road_type 路是“主要”还是“次要”道路
Start_junction_road_name 链接的起点交汇处的道路名称
End_junction_road_name 链接的终点交汇处的道路名称
Easting CP 位置的东经坐标
Northing CP 位置的北纬坐标
Latitude CP 位置的纬度
Longitude CP 位置的经度
Link_length_km 该 CP 的网络道路链接总长度(以公里为单位)
Link_length_miles 该 CP 的网络道路链接总长度(以英里为单位)
Pedal_cycles 自行车的计数
Two_wheeled_motor_vehicles 两轮机动车辆的计数
Cars_and_taxis 汽车和出租车的计数
Buses_and_coaches 公共汽车和长途汽车的计数
LGVs LGVs 的计数
HGVs_2_rigid_axle 两轴刚性轴重货车计数
HGVs_3_rigid_axle 三轴刚性轴重货车的计数
HGVs_4_or_more_rigid_axle 四轴或更多刚性轴重货车的计数
HGVs_3_or_4_articulated_axle 三轴或四轴铰接轴重货车的计数
HGVs_5_articulated_axle 五轴铰接轴重货车的计数
HGVs_6_articulated_axle 六轴铰接轴重货车的计数
All_HGVs 所有 HGVs 的计数
All_motor_vehicles 所有机动车辆的计数

在示例解决方案中,我们将使用一个较小的、清洗过的和过滤过的原始数据版本作为起点,这是我们在第八章中创建的。它具有与原始数据相同的结构,因此表 9.1 中的数据字典仍然适用。

9.1.3 预期成果

项目的输出是对哪些区域或哪些区域应集中初始努力的推荐。这些可能是已经有大量高自行车交通的区域,或者可能是自行车正在兴起或预计未来将有高自行车需求的区域。我们的建议可能包含建议纳入额外的数据集以继续分析。在开始任何基础设施工作之前,我们可能还想了解这些区域更多的情况,我们应该向利益相关者概述这项额外的工作。

第八章示例解决方案的输出是一个中间数据集,该数据集经过清理和过滤以供分析。本章涵盖的分析部分的输出将是我们所提出的结论和建议。

9.2 应该将自行车基础设施改进的重点放在哪里?

在我们开始分析时间序列数据之前,让我们回顾一下上一章中为准备数据以供分析所做的工作。图 9.1 显示了到目前为止所做的工作的流程图,突出显示了可能做出的替代决策。

figure

图 9.1 准备时间序列数据以供分析的流程图

现在,是时候查看项目分析部分的示例流程了。一如既往,我强烈建议您首先尝试自己完成项目。如果您有自己的分析来与之比较,示例解决方案将更加相关。需要重申的是,解决方案不是唯一的解决方案,而是一系列可能的决策和结论,您可以在过程中做出和达到。利用它来产生更多想法,并从不同的角度看待您如何处理相同的项目概述。

9.2.1 时间序列数据分析

到目前为止,我们已经清理了原始交通数据,并对其进行过滤,以便我们有一个长而完整的时间序列交通计数。现在,通过具体查看自行车情况,是时候将我们的努力集中在手头的问题上了。

计算时间序列数据中的分布

在第八章结束时,我们将数据导出为 Parquet 文件,以将数据清理与分析分离。因此,本节从再次读取相同导出数据开始。

我们知道在数据中,每年只有一个日历日进行了测量。我们想查看长期趋势,因此按小时粒度引入的噪声是我们想通过按年度汇总数据来移除的。让我们从这里开始。以下代码执行此操作,输出样本如图 9.2 所示:

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
traffic = pd.read_parquet("./data/time_series.parquet.gz")

cycling = (
    traffic
    .groupby(["Count_point_id", "Year"])
    ["Pedal_cycles"]
    .sum()
    .reset_index()
)

cycling.head()

figure

图 9.2 每个位置的年度自行车计数快照

现在的数据是每个日历年度每个位置 ID 一行。图 9.2 显示了一个有自行车交通的位置,但可能存在一个问题;许多位置 ID 可能几乎没有自行车交通。为了检查这一点,我们将查看所有年份中每个位置 ID 看到的自行车总数分布。首先,我们将再次聚合数据以删除年度值,并留下每个位置 ID 一行。这些数据是通过以下代码创建的,并显示在图 9.3 中:

cycling_totals = (
    cycling
    .groupby("Count_point_id")
    ["Pedal_cycles"]
    .sum()
)

cycling_totals.head()

figure

图 9.3 每个位置 ID 看到的自行车总数

现在,我们可以调查这些值的分布,以了解有多少位置有少量或没有自行车交通。我们预计数据将严重右偏,这意味着大多数值可能都接近零,而高值有一个长长的尾巴。为了解决这个问题,我们将向直方图添加更多区间,以希望更好地理解数据的分布。以下代码创建了图 9.4 中显示的直方图:

fig, axis = plt.subplots()

cycling_totals.hist(bins=50, ax=axis)

axis.set(
    xlabel="Frequency",
    ylabel="Total cycling traffic",
    title="Distribution of total cycling traffic by location ID"
)

plt.show()

figure

图 9.4 总自行车交通量的分布

如预期的那样,许多位置记录的自行车交通量几乎为零。让我们关注那些总自行车交通量超过一定数量的位置。我们如何知道应该使用什么值作为截止点(即,只考虑在一段时间内看到超过 X 辆自行车的位置)?我们应该了解这些值的四分位数,以了解构成数据前 25%或 50%的截止值。以下代码执行了此操作,输出结果如图 9.5 所示:

cycling_totals.describe()

figure

图 9.5 总自行车交通值的描述性统计

这告诉我们,一半的位置记录的总自行车数量为 216 辆或更多。由于我们专注于至少有 10 年覆盖范围的位置,这相当于平均每个测量日期有 10 到 20 辆自行车。这是否足以证明需要进行调查和提出建议,将取决于业务,因此对于这个问题,我们将假设一个足够高的交通水平,以便我们能够关注。

在我们开始削减这些数据之前,让我们更深入地了解它。例如,哪里是自行车交通最繁忙的地方?以下代码生成图 9.6 中的输出,并显示了按自行车交通量排名前 10 的位置 ID:

cycling_totals.sort_values(ascending=False).head(10)

figure

图 9.6 总自行车交通量最高的前 10 个位置 ID

我们有一个记录超过 19,000 辆自行车的位置,还有更多位置记录了数千辆。我个人喜欢在表格周围添加更多上下文,所以让我们来检查这个表格顶部的具体位置 ID。

调查单个数据点以获取更多上下文

由于我们的数据包含许多列,我们可以使用转置的技巧将其显示为单列。以下代码实现了这一点,我们可以像图 9.7 所示那样,将单行数据作为垂直表格来查看。由于空间原因,图中的某些最终列被省略了,但它们存在于解决方案笔记本中:

(
    traffic[traffic["Count_point_id"] == 942489]
    .head(1)
    .transpose()
)

figure

图 9.7 具有最高骑行交通量的位置 ID(数据表中一整行数据转置为列的一部分)

这是一条位于北伦敦伊兹灵顿的某种次要道路,伊兹灵顿是伦敦的一个区。具体是哪条道路没有提及。有Road_nameRoad_category列应该能告诉我们更多信息,但在这里它们并不提供信息。让我们查看数据字典来了解这些特定值的意义。作为提醒,数据字典包含在该章节的材料中,作为 PDF 文件。表 9.2 显示了Road_category列中我们可以预期的值。

表 9.2 Road_category列的数据字典
类别 类别描述
PM M 或 A 级主要公路
PA A 级主要道路
TM M 或 A 级干线公路
TA A 级干线道路
M 次要道路
MB B 级道路
MCU C 级或未分类道路

从这里,我们可以得出结论,最热门的骑行地点是一条非常次要或未分类的道路。数据字典中的一条注释说,“未分类道路(在数据集中称为‘U’)包括城市和农村地区的住宅道路,”因此这很可能是条住宅道路,因为伊兹灵顿不是一个农村地区。这些就是作为分析师你所拥有的额外背景信息,如果你有相关的领域知识的话。如果有疑问,请咨询领域专家。

如果我们真的想了解数据中的特定位置,我们可以使用纬度和经度坐标。这个位置的值是(51.52988, -0.106402),当输入到地图中时,显示图 9.8 中显示的地图点。

figure

图 9.8 具有最高骑行交通量的 OpenStreetMap 位置
活动内容:让你的数据生动起来

在这个项目中,无论何时你想了解更多关于位置 ID 的信息,你都可以在带有其坐标的地图上找到它。我鼓励你寻找这样的机会,甚至可以用谷歌街景等工具查看它们。作为分析师,我们很少有机会给我们的数据点添加这么多背景信息,所以我们应该充分利用这一点!

在不了解这个特定位置更多情况的情况下,我们只能猜测为什么它在数据中具有最高的骑行交通量。一些想法包括

  • 这可能仅仅是因为地图上可见的自行车店“交响乐自行车店”就在这条街上。

  • 这个区域可能特别适合骑行者,但也许这条住宅街道并没有什么特别之处,任何周围的街道都可能产生类似的结果。

  • 这可能是寻求避免周围较大道路交通的骑行者的一个流行捷径。

  • 在最初选择计数点时可能存在一些偏差。也许这个位置是因为其高自行车交通而被选择的原因

无论原因如何,这提出了许多有趣的问题,其中之一是,所有的自行车交通都在次要道路上,还是数据中存在有意义的重大道路?为了回答这个问题,我们将总自行车数据重新与原始数据连接,并找到总自行车量至少为 X 的位置,其中 X 是我们设定的值。让我们看看是否有任何看到总共有超过 1000 辆自行车的重大道路。以下代码执行此操作,并在图 9.9 中产生结果:

(
  traffic
  .merge(
    cycling_totals
      .reset_index()     #1
      .rename(columns={
        "Pedal_cycles": "Total_cycles"
      }),
    on="Count_point_id"
  )
  .query("Total_cycles > 1000 and Road_type=='Major'")
)

1 在与原始交通数据连接之前将自行车总数转换为 DataFrame

图

图 9.9 查找看到总共有超过 1000 辆自行车的重大道路时没有返回任何行

这告诉我们,没有看到超过 1000 辆自行车的重大道路实例。如果我们把那个阈值降低到 100 呢?这意味着在给定的一天平均只看到 5-10 辆自行车的重大道路。以下代码执行此操作,并在图 9.10 中产生结果:

bikes_100_plus = (
    traffic
    .merge(
        cycling_totals
            .reset_index()
            .rename(columns={"Pedal_cycles": "Total_cycles"}),
        on="Count_point_id"
    )
    .query("Total_cycles > 100 and Road_type=='Major'")
)

bikes_100_plus

图

图 9.10 看到总共有超过 100 辆自行车的重大道路上的交通数据

即使当我们只考虑总共 100 辆自行车时,似乎在主要道路上没有看到自行车交通,至少在我们使用的简化版数据中是这样。因此,在这个分析中,我们可能不会专注于建议改善主要道路上的自行车基础设施。然而,自行车交通的缺失本身可能是一个有趣的发现——我们可以与我们的利益相关者讨论,因为对自行车如何绕行主要道路的更深入调查可能对他们来说很有趣。

让我们将对自行车交通分布的调查添加到我们的分析图中,其最新版本如图 9.11 所示。

图

图 9.11 到目前为止的分析,直到调查自行车交通的分布

专注于手头的资料,现在让我们开始根据它们的自行车交通模式查看感兴趣的位置,从数据首次捕获以来自行车量增加的位置开始。

寻找包含上升趋势的时间序列

我们的第一步是定义我们所说的“上升”。我们是否希望看到某个地点的自行车年复一年持续增长才能符合条件?由于我们每年只有一天的数据,所以会有噪声,因此这个标准可能过于严格。让我们寻找最新测量值高于第一个值的地点。这是“自行车增加”的粗略代理,但我们可以将数据过滤到增长最大的地点。

为了实现这一点,我们需要分别对每个位置 ID 的行进行处理,确保它们按时间顺序排列,然后计算第一行和最后一行之间的差异。我们应该计算绝对变化和百分比变化以验证我们的结果。在计算百分比变化时,还需要考虑除以零的错误。以下代码计算了每个位置的第一行和最后一行之间的绝对变化和百分比变化,最终得到如图 9.12 所示的数据库:

def cycling_diff(group):     #1
    return group.values[-1] - group.values[0]

def cycling_diff_pct(group):     #2
    if group.values[0] == 0:     #3
        return np.inf
    diff = group.values[-1] - group.values[0]
    return diff / group.values[0]

cycling_diffs = (
    cycling
    .sort_values(["Count_point_id", "Year"])
    .groupby("Count_point_id")
    .agg(     #4
        diff=("Pedal_cycles",cycling_diff),
        diff_pct=("Pedal_cycles",cycling_diff_pct)
    )
)

cycling_diffs

1 定义一个函数来计算组中遇到的第一行和最后一行之间的差异

2 定义一个函数来计算百分比变化

3 考虑除以零的错误

4 将这两个函数应用于每个位置 ID 组

figure

图 9.12 每个位置自行车总数的绝对和百分比差异

在这里,我们有各种结果。当第一个测量日期没有自行车时,将会有无限大的值,以及随着时间的推移出现正负变化的情况。在进一步调查这个表格之前,验证我们的计算是一个好主意。让我们看看其中一个位置,看看原始数据是否支持我们计算的变化值。以下代码执行此操作,生成如图 9.13 所示的结果:

cycling[cycling["Count_point_id"] == 900056]

figure

图 9.13 位置 900056 的原始年度自行车数据

我们发现,在 2007 年,遇到了 24 辆自行车,而在 2019 年只有 14 辆,减少了 42%,如图 9.12 所示。我们可以检查更多示例,以使我们确信我们的计算是正确的。然后,我们可以查看一些百分比变化最大的自行车流量增加情况。以下代码执行此操作,并生成如图 9.14 所示的表格:

figure

图 9.14 自行车百分比变化最高的位置
biggest_diffs = (
    cycling_diffs
    [np.isinf(cycling_diffs["diff_pct"]) == False]     #1
    .sort_values("diff_pct", ascending=False)
    .head(10)
)

biggest_diffs

1 从考虑中移除无穷大值

警告 当遇到除以零的情况时,请确保您了解您选择的工具如何表示无穷大。在pandas中,我们使用np.inf,这是numpy库中的一个特定值。在排序时,它被认为大于所有其他整数,因此在这种情况下,我们确保在排序之前从考虑中移除这些行。

我们有一些有趣的情况需要调查。是时候绘制这些时间序列图,而不是依赖数值计算来确定有趣的模式了。以下代码生成了如图 9.15 所示的图表。为了清晰起见,我只包括了一些时间序列:

fig, axis = plt.subplots(figsize=(10, 6))

biggest_diff_ids = biggest_diffs.index

ids_to_plot = [943399, 931883, 946565, 990552]

diffs_to_plot = (
    cycling
    .query("Count_point_id in @ids_to_plot")
)

markers = ["o", "s", "P", "^"]

for i, point_id in enumerate(diffs_to_plot["Count_point_id"].unique()):
    point_series = cycling[cycling["Count_point_id"] == point_id]
    (
        point_series
        .set_index("Year")
        ["Pedal_cycles"]
        .plot(ax=axis,
              label=point_id,
              marker=markers[i],
              alpha=0.8)
    )

axis.set(
    xlabel="Year",
    ylabel="Pedal cycles encountered",
    title="Cycling traffic for locations with the highest increase"
)

axis.legend()

plt.show()

figure

图 9.15 自行车流量增加最多的几个位置

这表明在各个地点确实有自行车交通量的真实增长。然而,我们应该从不同的角度来审视这个问题,因为我们的数据性质——每年只在某一天计数车辆的事实——意味着所有数值都存在偏差。可能有特殊事件,如道路关闭、公共假日,或者只是工作日之间的不同模式,这些都可以解释这些增长。让我们将这一最新步骤添加到我们不断增长的分析图中,如图 9.16 所示。

figure

图 9.16 显示了分析过程,包括最新步骤,即寻找自行车交通量上升的地点

另一个我们可以考虑的角度是寻找自行车在总体交通中占较大比例的地点。

识别具有特定特征的时间序列

让我们看看如何计算自行车在总体交通中的百分比。我们一直在使用Pedal_cycles列来计数骑自行车的人,以及All_motor_vehicles来计数所有交通。然而,“机动车”这个短语暗示可能自行车不包括在内。在计算任何东西之前,我们需要验证这一点。表 8.1 中的数据字典没有回答这个问题,因为它只是说该列测量“所有机动车的计数。”幸运的是,PDF 版本包含了更多信息。第 10 页包含一个名为“车辆类型”的部分,其中包含以下内容:“所有机动车:除自行车外的所有车辆。”图 9.17 显示了文档的相关部分。

figure

图 9.17 显示数据字典中关于“所有机动车”含义的部分的截图

如预期的那样,为了得到包括自行车在内的总车辆数,我们需要将这些列相加。我们还想对近期观察结果进行加权,因此我们只取每个位置点的最后日期。在某些情况下,这可能是在几年前,但至少会给我们提供最新的数据。以下代码中的步骤如下:

  1. 将交通数据过滤到每个位置 ID 的最后观察日期

  2. 通过将相关列相加来计算总交通量

  3. 按位置 ID分组数据以减少粒度,使每个位置只有一行。

  4. 将总交通列和自行车列的数值求和

  5. 为每个位置计算自行车百分比

接下来是用于此目的的 Python 代码,以及结果数据集的快照,如图 9.18 所示:

annual_bike_traffic = (
    traffic
    [traffic['Count_date']
 == traffic.groupby('Count_point_id')['Count_date'].transform('max')]
    .assign(
        all_traffic=lambda x: x["Pedal_cycles"] + x["All_motor_vehicles"]
    )
    .groupby(["Count_point_id", "Year"])
    [["Pedal_cycles", "all_traffic"]]
    .sum()
    .assign(
        pct_cycles = lambda x: x["Pedal_cycles"] / x["all_traffic"]
    )
    .sort_values("pct_cycles", ascending=False)
)

annual_bike_traffic.head()

figure

图 9.18 显示了每个位置的绝对值和百分比总交通量和自行车交通量

图 9.18 显示了每个地点的 ID、我们拥有的最新数据年份以及交通计算,包括归因于骑行的交通百分比。我们已经开始注意到一些骑行交通百分比高的地点。让我们更详细地研究其中的一个例子。我们将放大图 9.18 中的第一行并确定其位置。输出显示在图 9.19 中。

(
  traffic[(traffic["Count_point_id"] == 942489)
↪  & (traffic["Year"] == 2019)]
  .head(1)
  .transpose()
)

figure

图 9.19 骑行交通百分比高的特定地点

如果我们在地图上查看这个点,我们会找到与图 9.7 中看到相同的地点。这实际上是位于伦敦北部的伊兹灵顿的同一条郊区街道,其绝对骑行交通量最高。看到从不同角度验证的相同结果是有用的。

注意:花一分钟时间查看图 9.6 中产生的表格中出现的其他一些骑行交通量高的地点。你注意到任何模式吗?

观察一些这些地点,我们发现它们似乎都是小型的郊区街道,可能被用作通勤捷径。图 9.20 显示了到目前为止的过程,突出了我们正在进行一些并行调查,结果将在最后汇总。

figure

图 9.20 分析的当前状态

在思考这些热门地点的过程中,通勤路径似乎是一个值得进一步探索的有用途径,因此让我们将焦点转移到那里。

在时间序列中识别时间模式

为了调查通勤模式,我们将做两件事:

  • 查看每个地点骑行最繁忙的时间。换句话说,人们一天中什么时候骑行最多?

  • 一旦我们理解了这一点,我们将确定在通勤高峰时段骑行交通量最高的地点。

第一项要求我们计算每小时平均的骑行交通量。我们可以决定是否将结果平均到所有年份的数据中,或者只关注最新的数据。这两种方法都有其优缺点。在整个数据上平均可能会否定人们通勤方式在 10-20 年内可能发生的变化。仅使用最近的数据意味着我们放大了那一年可能发生的任何特定偏差(例如,原本不存在的道路封闭)。我们仍然希望偏向近期,因此我们将选择后者。一个更复杂的方法可能是对时间进行加权平均,将最近的数据赋予更高的权重。

这个特定的计算过程看起来是什么样子?我们将执行以下步骤:

  1. 将骑行数据过滤到每个地点最近的一年—这将给我们一个关于骑行模式的最新视角。

  2. 计算每天每小时发生的自行车交通量的百分比—使用百分比意味着无论位置的热门程度如何,结果都是可比较的。

  3. 通过小时可视化这些百分比值的分布—使用我们的“从结果开始”方法,我们想象最终的可视化。在这种情况下,它将是一系列箱线图,每个代表一天中的某个小时,而单独的点代表该小时某个位置的自行车交通量的百分比。

从技术角度来看,这里的难点在于第一步。我们需要同时计算每个位置的交通总量,并比较每小时值与每日总量的值。这要求我们同时拥有每小时和每日的值。如果您是 SQL 用户,这将通过使用带有PARTITION BY关键字的窗口函数来实现。在pandas中,我们需要做一些稍微不同的事情,实际上,我们可以求助于我们最喜欢的 LLM。

我询问 ChatGPT 如何实现这一点,它的第一个结果建议分别计算每小时和每日的总数,然后通过位置 ID 将两个表连接起来。这是一个非常好的方法,但我特别询问了使用类似窗口函数的方法。图 9.21 显示了我在引导 ChatGPT 到达我想要的结果的部分对话。

图

图 9.21:关于pandas中窗口函数与 ChatGPT 对话的片段

因此,我们可以使用transform方法来实现所需的结果,即在原始数据旁边创建一个列,以捕捉每个位置 ID 的每日自行车交通总量。让我们首先过滤我们的交通数据,仅包括每个位置的最近日期:

traffic_max_dates = (
    traffic[
        traffic['Count_date']
        == traffic.groupby('Count_point_id')['Count_date'].transform('max')
    ]
    .copy()
)

现在,我们将traffic_max_dates DataFrame 聚合为每小时的每位置一行。以下代码执行此操作,并在图 9.22 中产生输出:

cycling_daily_hourly = (
    traffic_max_dates.groupby(
        ["Count_point_id", "Count_date", "hour"]
    )
    ["Pedal_cycles"]
    .sum()
    .reset_index()
)

cycling_daily_hourly.head()

图

图 9.22:仅针对每个位置的最新日期的每小时自行车交通量

现在,我们需要创建一个列来测量与这些数据并行的总自行车交通量,以便能够计算每个位置每天每个小时的自行车交通量的百分比。这就是我们使用 ChatGPT 向我们展示的技巧的地方。以下代码添加了这个列并计算了每小时的交通百分比,结果如图 9.23 所示:

cycling_daily_hourly['TotalDailyCount'] = (
    cycling_daily_hourly
    .groupby(['Count_point_id', 'Count_date'])
    ['Pedal_cycles']
    .transform('sum')     #1
)

cycling_daily_hourly['hourly_pct'] = (
    cycling_daily_hourly['Pedal_cycles']
    / cycling_daily_hourly['TotalDailyCount']
)

cycling_daily_hourly

1 Transform 计算每个位置 ID 的交通总量,因此我们可以将其作为额外列添加,而无需额外的连接。

图

图 9.23:附加了额外百分比计算的每小时数据

这是一个我们需要在继续之前验证那些每日计数和百分比值的例子。让我们先看看第一个例子,位置 900056。首先,我们从原始数据中计算该位置的最新日期,以确保我们有正确的数据:

traffic_max_dates.loc[
↪ traffic_max_dates["Count_point_id"] == 900056, "Count_date"].max()

输出结果是2019-05-20,这与图 9.23 中的表格相符。现在,我们想查看该位置 ID 和日期的所有原始数据,以查看总自行车计数是否真的是 14,因此百分比是否也是正确的。以下代码找到了原始数据,如图 9.24 所示:

(
    cycling_daily_hourly
    [
        (cycling_daily_hourly["Count_point_id"] == 900056)
        & (cycling_daily_hourly["Count_date"] == "2019-05-20")
    ]
)

figure

图 9.24 用于验证每小时自行车交通百分比计算的原始数据

从这些原始数据中,我们可以注意到将Pedal_cycles列相加的总数与总数相符,而hourly_pct列中的百分比也是正确的。总结一下,我们发现在这个地点,大约三分之一的自行车交通出现在下午 5 点到 6 点之间,其余的则分散在一天中的其他时间。我们可以使用这些数据覆盖所有位置 ID,以确定在一天中的不同时段内,我们想象中的箱线图中看到的自行车交通流量的百分比。以下代码创建了图 9.25 所示的箱线图:

fig, axis = plt.subplots()

cycling_daily_hourly.boxplot(
    column="hourly_pct",
    by="hour",
    ax=axis)

axis.set(
    xlabel="Hour",
    ylabel= "% of cycling traffic in an hour",
    title="What times of the day does most cycling traffic occur?"
)
plt.suptitle(None)

plt.show()

figure

图 9.25 箱线图显示一天中自行车交通的分布情况

这些箱线图中有很多噪声和异常值,但如果我们关注中位数线,即箱子的中间,我们会注意到交通量在下午 5 点附近有所上升,以及上午 8 点的峰值随后急剧下降。这告诉我们数据中存在通勤模式,因为上午 8 点到 9 点以及下午 5 点到 6 点之间的自行车交通量百分比更高。下午 3 点到 6 点之间中位数之间的接近性也表明,尽管人们通常在相同的时间通勤去工作,但人们通勤下班的时间差异更大。

拥有了这些知识,我们现在可以找到通勤时段中自行车交通流量最高的地点。我们可以将这些地点视为“通勤热点”——在这些区域,自行车交通流量中有很大一部分是由通勤引起的。我们已经有按小时汇总的数据,如图 9.23 所示,因此我们可以从中找到每个自行车地点的最高自行车交通流量时段。有几个边缘情况需要首先做出决策:

  • 如果所有自行车交通量都是零会发生什么?我们可能需要输出一个缺失值来表示这个特定操作对该位置没有意义。

  • 如果有多个小时的自行车交通量相同,我们可以选择第一个或最后一个遇到最大值的小时,或者以某种方式平均结果。平均一天中的两个不同小时似乎没有意义,因此我们将决定使用遇到最大值的第一个小时。也就是说,如果最高的自行车交通量出现在上午 8 点和下午 1 点,我们将输出上午 8 点。

以下代码定义了一个函数来计算给定组的最高小时,即单个地点 ID,然后使用它创建一个包含每个地点 ID 在最后记录的测量年中自行车交通最高的小时的数据集。这个聚合数据的快照如图 9.26 所示:

def get_highest_hour(rows):
    if rows["Pedal_cycles"].min() == rows["Pedal_cycles"].max():
        return np.nan

    return (
        rows
        .sort_values(by=["Pedal_cycles", "hour"], ascending=[False, True])
        .head(1)
        ["hour"]
        .values[0]
    )

highest_hours = (
    cycling_daily_hourly
    .groupby("Count_point_id")
    .apply(get_highest_hour)
)

highest_hours.head()

figure

图 9.26 每个地点 ID 的自行车交通最高小时

我们可能会发现一些缺失值的情况,其中最低和最高的自行车交通量相同,包括整个周期都是零的情况。然而,在图 9.26 中,我们看到实际最高小时被输出的例子。我们需要考虑的一个场景是,当最高自行车交通量发生在多个小时时,我们需要解决平局的情况。以下代码检索了这样一个示例的原始数据,如图 9.27 所示:

cycling_daily_hourly[cycling_daily_hourly["Count_point_id"] == 941463]

figure

图 9.27 展示了地点 941463 的小时数据,其中 8 点和 16 点的小时值都有最高的自行车交通量

因为在我们的函数内部,我们是按照自行车交通量的升序以及小时的升序来排序数据的,并且返回了遇到最高交通量的最早实例。我们可能选择明确地处理这个问题,或者接受在计算哪个小时是某个地点最繁忙时可能存在对早期时间的轻微偏差。在这个例子中,我们将选择后者,因为发生平局的可能性不太常见。

如果我们观察最繁忙时段的分布,我们就能看到大多数地方自行车交通高峰出现在什么时间。以下代码创建了图 9.28 所示的直方图:

fig, axis = plt.subplots()

highest_hours.hist(bins=20, ax=axis)

axis.set(
    xlabel="Hour of peak cycling traffic",
    ylabel="Frequency",
    title="Distribution of peak cycling traffic hours across locations"
)

plt.show()

figure

图 9.28 各地点自行车高峰时段的分布

这个图表加强了这样一个观点:大多数地点的自行车交通高峰发生在通勤时段。让我们总结一下到目前为止关于自行车模式学到的东西:

  • 自行车趋势存在变化,但最重要的是,随着时间的推移,有多个地点的自行车交通量有所增加。

  • 在自行车交通的百分比上也有变化,但关键的是,有一些地方自行车交通是主要交通方式。

  • 人们骑自行车的时间也有变化,但有一些地方的自行车交通通勤模式。

  • 在此基础上,还有一些地方的通勤时段是自行车交通最繁忙的时段。

这些标准中的每一个都可以用来找到与自行车相关的兴趣点。我们如何决定哪个标准是有意义的?

注意:如果你对“决定什么最好”是主要目标的例子感兴趣,请参阅第四章,该章节全部关于选择正确的指标。

我们需要使用领域知识并与利益相关者交谈,以获得对这个问题的真正理解,但到目前为止,似乎没有不使用所有这些标准的良好理由。因为我们试图将我们的地点过滤到最感兴趣的几个,所以让我们尝试找到符合所有这些标准的地点,特别是,

  • 骑自行车交通百分比高的地点,即至少 X%,其中我们必须定义 X

  • 骑自行车交通有所增加的地点,即至少增加 Y%,其中我们必须定义 Y

  • 骑自行车用于通勤的地点,即高峰骑自行车交通是在早上 8 点到 9 点或下午 5 点到 6 点之间

如果应用所有这些标准导致结果过少,我们总是可以通过寻找下午 4 点到 7 点之间的通勤时间来扩大范围,例如。在我们将结果组合起来寻找感兴趣的骑自行车地点之前,让我们回顾一下这个过程。图 9.29 显示了分析的最新状态。

figure

图 9.29 在我们将结果组合成最终建议之前的分析过程

让我们应用我们确定将用于选择地点的不同标准,看看我们是否能找到什么。

结合标准以识别感兴趣的时序

要找到符合多个标准的地点,我们可以执行一个包含多个过滤器的单一、大型查询,或者为每个标准创建过滤版本并在最后将它们组合起来。我们更倾向于后者,因为它允许我们单独调查每个子集。如果我们什么都没有找到,想要调查哪个标准过于严格导致没有结果,这可能是有用的。

让我们先过滤地点,只保留那些有相当比例的交通是骑自行车的。我们如何知道使用什么百分比作为截止点?我们实际上还没有查看这个百分比的分布,所以我们将从这里开始,生成的直方图将显示数据的大多数所在位置。以下代码创建了图 9.30 中的直方图,我们将用它来做这个决定。我们将使用我们已创建的 DataFrame,我们称之为annual_bike_traffic,它包含最新年份中分配给骑自行车的交通百分比:

fig, axis = plt.subplots()

annual_bike_traffic["pct_cycles"].hist(bins=10, ax=axis)

axis.set(
    xlabel="Percentage of traffic that is cycling",
    ylabel="Frequency",
    title="Distribution of cycling traffic percentages"
)

plt.show()

figure

图 9.30 每个地点因骑自行车而产生的交通百分比分布

这个直方图显示,大多数地点的交通中骑自行车的比例不到 10%。这感觉像是一个很好的截止点来区分有显著骑自行车交通的区域。以下代码实现了这个过滤器并创建了一个过滤后的地点 ID 列表,其中一部分在图 9.31 中显示:

BIKE_PERCENTAGE_CUTOFF = 0.1

highest_cycling = (
    annual_bike_traffic
    [annual_bike_traffic["pct_cycles"] >= BIKE_PERCENTAGE_CUTOFF]
    .reset_index()
    ["Count_point_id"]
    .to_list()
)

print(len(highest_cycling))

highest_cycling[:10]

figure

图 9.31 有显著骑自行车交通的地点 ID

这表明有 38 个位置至少有 10%的交通是由于骑自行车造成的。现在,我们想要创建一个类似的位置列表,但这次是那些在第一个和最后一个测量日期之间至少有 Y%自行车增长的位置。Y 的值将有些任意,如果我们想返回更多位置,我们可能会决定调整它。我们将从 50%开始,看看能得到什么。以下代码创建了这个过滤后的列表,它产生了一个类似于图 9.31 所示的数字列表。同样,我们已经从之前的 DataFrame cycling_diffs中计算出了底层值:

DIFF_CUTOFF = 0.5

biggest_increases = (
    cycling_diffs
    [(cycling_diffs["diff_pct"] >= DIFF_CUTOFF)
     & (np.isinf(cycling_diffs["diff_pct"]) == False)]     #1
    .index
    .to_list()
)

print(len(biggest_increases))

print(biggest_increases[:10])

1 我们需要排除无穷大值,以免它们出现在计算中。

这段代码的结果告诉我们有 230 个这样的位置。最后,我们将创建第三个位置列表,这将是在最高上下班时间要么是上午 8 点到 9 点,要么是下午 5 点到 6 点的位置。以下代码使用之前创建的highest_hours DataFrame 来完成这项工作:

highest_commuting = (
    highest_hours
    .loc[lambda x: x.isin([8, 17])]
    .index
    .to_list()
)

print(len(highest_commuting))

print(highest_commuting[:10])

输出是一个包含 195 个位置的列表。我们现在有三个位置列表需要合并。我们想知道哪些位置 ID 出现在所有三个列表中。在 Python 中,我们可以通过集合理论非常容易地做到这一点。在这种情况下,一个集合是包含唯一元素的特定数学概念。

提示:如果您是 Python 用户,每当您遇到唯一值问题时,考虑是否使用集合是合适的。它们通常是快速简单获得复杂结果的方法,例如找到出现在两个或更多集合中的值。

通过将我们的列表转换为集合,我们可以计算集合的交集,即同时出现在两个集合中的元素。您可以将其视为找到三个圆的维恩图中心,如图 9.32 所示。

figure

图 9.32 使用集合理论识别具有多个标准的位置的说明

以下代码将我们的列表转换为集合,并执行所有三个集合的交集,以发现是否有任何重叠。结果如图 9.33 所示:

top_cycling_locations = (
    set(highest_cycling)     #1
    .intersection(set(biggest_increases))     #2
    .intersection(set(highest_commuting))     #3
)

print(len(top_cycling_locations))

print(top_cycling_locations)

1 将第一个列表转换为集合

2 在两个集合之间找到交集

3 在这个结果和第三个集合之间找到交集

figure

图 9.33 最终感兴趣的位置列表

看起来我们的标准并不太严格,我们有一些结果——确切地说,是 11 个。这些位置都有由于骑自行车造成的显著百分比交通,自行车数量在时间上至少增加了 50%,并且在上下班高峰时段有峰值。是时候深入研究这些结果并了解它们与哪些位置相关了。让我们在原始交通数据中查找这些位置 ID,提取仅位置信息,并将每个位置限制为单行。我们还将按地区和地方当局对结果进行排序,以便更好地看到这些顶级位置在地理上的分布。以下代码执行所有这些操作,并产生了图 9.34 中的表格:

LOCATION_COLUMNS = ['Count_point_id', 'Region_name',
                    'Region_ons_code', 'Local_authority_id',
                    'Local_authority_name', 'Local_authority_code',
                    'Road_name', 'Road_category', 'Road_type']

(
    traffic[traffic["Count_point_id"].isin(top_cycling_locations)]
    .drop_duplicates(subset=["Count_point_id"])
    [LOCATION_COLUMNS]
    .sort_values(["Region_name", "Local_authority_name"])
)

figure

图 9.34 顶级自行车地点信息

这些地点遍布全国。不出所料,伦敦有很多,但结果从西南部的布里斯托尔一直延伸到苏格兰北部的爱丁堡。一旦我们逐一验证这些地点,确保它们都不是数据中的伪象,我们就可以向我们的利益相关者展示我们的初步发现。然而,在我们总结我们的发现之前,让我们先了解一下预测,因为这是时间序列数据可以发挥强大作用的方式之一。

时间序列预测

我们有足够的资料向利益相关者展示并开始考虑进一步的工作,但鉴于这是一个时间序列问题,我们应该考虑我们如何根据可用数据预测自行车趋势。这将帮助我们确定我们预计自行车将很快显著增加的地点,以及开辟其他机会,例如主动交通管理。

要成功预测一个时间序列,我们通常需要它遵循一些属性:

  • 时间序列应保持一致的时间间隔。 我们已经通过创建年度时间序列并移除特定的日期成分来实现这一点,因为每年的测量通常不在同一天进行。

  • 时间序列应无间断。 我们已经过滤了我们的时间序列,以确保这一点。

  • 数据应尽可能包含尽可能多的完整周期。 在年度数据的情况下,我们不一定有周期,所以在这种情况下,这意味着我们拥有的数据越多,越好。

  • 还有一些其他属性,例如平稳性,某些模型要求时间序列展示出来。 虽然这不是详尽的,因为其他模型可以自动处理这一点。

为了检验我们的时间序列是否可以成功预测,让我们更详细地考察其中之一。让我们选择一个表现出趋势的时间序列,即我们“最大增长”列表中具有最大可能覆盖范围的时间序列。以下代码计算了这些时间序列的覆盖范围,并生成了图 9.35 中的表格,我们将从中选择我们的样本:

(
    traffic
    .query("Count_point_id in @biggest_increases")
    .groupby("Count_point_id")
    ["Year"]
    .agg(["min", "max"])
    .assign(diff=lambda df_: df_["max"] - df_["min"])
    .sort_values("diff", ascending=False)
    .head()
)

figure

图 9.35 自行车增长最大的地点及其覆盖范围计算

这些地点中有些有长达 19 年的数据,我们将从中选择一个进行调查研究。我们将随意使用该列表中的第一个地点来测试我们预测这些地点级时间序列的能力。以下代码为该地点提取原始数据,并为我们创建了一个单一年度自行车量的时间序列。该时间序列如图 9.36 所示:

cycling_ts = (
    traffic[traffic["Count_point_id"] == 996188]
    .groupby("Year")
    ["Pedal_cycles"]
    .sum()
)

cycling_ts

figure

图 9.36 地点 996188 的自行车时间序列

需要注意的一个 Python 特定方面是,序列的索引是一个整数,而为了使时间序列操作更容易,它应该是一个日期。以下代码解决了这个问题,并允许我们绘制时间序列,如图 9.37 所示:

cycling_ts.index = pd.to_datetime(cycling_ts.index, format='%Y')

fig, axis = plt.subplots()

cycling_ts.plot(ax=axis)

axis.set(
    xlabel="Time",
    ylabel="Number of bicycles observed",
    title="Cycling traffic over time at location 996188"
)

plt.show()

figure

图 9.37 特定位置的骑行交通时间序列

我们有一组 19 年的年度值时间序列,呈现上升趋势。我们经常想要查看时间序列是否存在季节性,即沿时间序列在相同点重复出现的模式,但在年度值的背景下这不太有意义。

为了理解时间序列的各个组成部分,我们可以将序列分解为趋势、季节和残差成分。趋势告诉我们平均值是否有长期变化,也就是说,值是否向特定方向移动。季节性是依赖于我们在时间序列中的位置的定期运动。残差是在考虑了趋势和季节性之后剩下的部分。意外的高残差意味着在这一点上时间序列有某些异常,这不能仅由其基本趋势和季节性模式来解释。

在 Python 中,statsmodels模块有各种时间序列方法,包括分解,我们将将其应用于我们的时间序列。我们将实现 STL(使用 LOESS 进行季节和趋势分解[局部估计散点图平滑]),并尝试提取时间序列的趋势、季节和残差成分。在许多这些方法中,我们需要选择一些将改变结果的参数值。在 STL 的情况下,我们需要选择周期、对趋势应用多少平滑以及构成季节的观测数。

在我们的案例中,我们拥有没有明显、规律季节性模式的年度数据。由于年度时间序列是这些方法中的一些特殊案例,我们可以求助于我们的 AI 工具以获得进一步指导。我们使用的库的文档也可能包含有关如何选择参数值的提示。在这种情况下,我要求 ChatGPT 就选择一个没有真实季节性的年度时间序列的这些值提供建议。其部分响应如图 9.38 所示。

figure

图 9.38 ChatGPT 关于 STL 分解方法参数的响应

这个答案建议,在没有明显季节性的情况下,选择参数值将取决于最适合问题的方案。它建议季节性参数选择一个低值,并在可能的情况下优先考虑默认值。我们将周期设置为2,季节性设置为3,并检查输出。代码如下,并生成如图 9.39 所示的分解图:

from statsmodels.tsa.seasonal import STL

stl = STL(cycling_ts, period=2, seasonal=3)
result = stl.fit()

result.plot();

figure

图 9.39 单个时间序列的季节分解

这个图示从上到下展示了原始数据、趋势成分、季节性模式和残差。在趋势成分中,我们发现时间序列的一个平滑版本,看起来整体呈稳步上升,可能到结尾时趋于平稳。因为我们设置了较低的平滑值,季节性成分模仿了原始数据中的峰值。这是为了强化这样一个观点:可能不存在重复的季节性变化,但有明显的峰值需要调查。残差在开始时较大,之后似乎始终围绕零值中心。我们在 2002 年左右遇到的峰值,如果我们仅仅使用趋势和季节性变化来建模这个序列,将会是出乎意料的,这告诉我们存在外部因素使得这个序列的开始部分更难以预测。

总的来说,我们了解到时间序列似乎大部分是可以预测的,除了可能在 2002 年左右。我们的下一步是构建一个预测模型。如果我们有领域专业知识,我们可以调查 2002 年左右残差较高的可能原因,并将这些原因作为额外的变量构建进去。目前,我们将使用标准的预测方法 ARIMA 来预测未来几年的自行车交通流量。

自回归积分移动平均,或 ARIMA,是另一种需要用户选择某些参数的方法。理想情况下,这是基于哪种参数组合产生最准确模型而自动完成的。如果你是 R 用户,你可能已经使用过 R 的auto.arima方法。在 Python 中也有等效的方法,例如pmdarima模块中的方法,我们在这里将使用它。以下代码在可用数据上计算最佳 ARIMA 模型,然后将其预测值与观察值进行对比绘图。该图示在图 9.40 中展示:

import pmdarima as pm

model = pm.auto_arima(cycling_ts, seasonal=False)     #1

training_predictions = model.predict_in_sample()     #2
forecast = model.predict(3)     #3

predictions = pd.concat(     #4
    [training_predictions,
     forecast]
)

fig, axis = plt.subplots()

axis.plot(cycling_ts, label="Observed")
axis.plot(predictions,
          label="Predicted",
          marker="^",
          color="orange",
          alpha=0.8)

axis.set(
    xlabel="Time",
    ylabel="Number of bicycles observed",
    title="Actual vs. predicted cycling traffic"
)

axis.legend()

plt.show()

1 自动找到最佳的 ARIMA 模型

2 在训练数据上计算预测值

3 预测三个时间点的前瞻值

4 将预测值和预测值合并为单一时间序列进行绘图

figure

图 9.40 某特定位置时间序列的实际值与预测值对比

这个图示告诉我们什么?

  • ARIMA 模型错过了一些峰值并试图纠正它们,这在 2002 年左右尤为明显。

  • 由于 ARIMA 模型具有一定的自我纠正能力,它试图匹配观察到的数据的形状。

  • 2008 年至 2012 年之间的意外平稳线也令 ARIMA 模型感到惊讶,2013 年的后续峰值也是如此。

  • 最终,由于缺乏足够的前瞻性信息,ARIMA 模型会回归到通过预测下降然后达到峰值来预测未来的平均行为。

这是一个特别困难的预测问题,因为我们没有很多数据,而且除了略微上升的趋势外,没有很多模式可以利用。我们每年只从单一天进行测量的事实增加了难度。ARIMA 能为我们做的最好的事情是根据最近的数据点预测平均行为。为了改善这个预测,理想情况下,我们会有更多的历史数据和额外的变量,这些变量可以帮助预测自行车交通的变化。

话虽如此,我们仍然可以在所有自行车时间序列上运行这个预测代码,对于那些有最新数据的,我们可以查看下一个时间点的预测。如果这些预测与之前的值相比仍然很高,我们可以利用这一点来识别具有上升趋势的时间序列。这可能是一种更复杂的方法来找到自行车交通增加的地方,因为那些在系列中期达到峰值后自行车交通量下降的地方可能会被更成功地过滤掉。然而,就目前来看,我们似乎不会从我们的建议中获得太多,所以现在是时候得出一些结论并考虑下一步了。

在得出结论之前,让我们看一下记录分析的最终图。我们对如何缩小数据到感兴趣的位置进行了三项并行调查。每一项都伴随着关于截止点的决定(例如,我们将多少百分比的增加视为有意义的)。这些决策点在图上合并为一个,因为我们是在最终阶段一起做出的,但它们代表了我们需要做出的三个单独的决定。图 9.41 显示了我们在分析过程中采取的最终路径。

figure

图 9.41 最终分析图

现在我们将我们的发现总结成结论和建议。

9.2.2 项目结论和建议

让我们回顾一下我们的初始目标。我们支持的项目第一阶段是确定全国范围内最适合改善自行车基础设施的地方。这意味着要找到已经具有显著或正在增加的自行车交通流量的地方。让我们也回顾一下我们最终做了什么,以及我们在过程中做出的决定。

我们分析的结果是确定了 11 个符合多个标准、与我们目标相关的地方。我们可以将这些地点以列表形式呈现,甚至作为地图上的点。实际上,地图可视化将是一个使数据故事生动起来的好方法。重要的是要与我们的领域专家讨论这些发现。

至于进一步的工作,这将由我们的利益相关者对话来指导,但我们可以从这里出发的方向包括

  • 回顾我们的选择和假设——我们做出的一个决定是移除覆盖范围不足的时间序列。然而,我们最终没有使用预测,所以如果我们放宽这个覆盖范围标准,允许更短的时间序列,我们可能会发现更多感兴趣的位置。

  • 在分析中包含额外的数据集——将我们的发现与关于特殊事件、道路关闭或甚至天气的数据进行交叉引用,将为我们提供有关有机增长自行车交通的位置以及哪些位置的增长可能是由于外部因素造成的更清晰的画面。

  • 地理数据可视化——这也有助于确定在近距离内有多个感兴趣位置的地方。例如,我们推荐的三个感兴趣的位置都在兰贝思区。如果将这些位置绘制在地图上,这种洞察力将更容易得出。

活动:使用这些数据的进一步项目想法

考虑一些其他的研究问题,你可以用这些数据回答,这些问题与本章的项目无关。以下是一些帮助你开始的想法:

  • 当你比较不同地理级别的交通模式时,会发生什么,比如地区或地方当局级别?

  • 不同类型的车辆的交通模式有何不同?例如,汽车、公交车和重型货车(HGVs)的热点在哪里?

  • 根据可用数据,哪个国家的哪个部分最需要额外的道路基础设施?

  • 如果你曾经对地理空间数据分析感兴趣,这是一个实验它的机会。交通部网站包含形状文件,其中包含主要道路网络的位置,可用于地理空间分析和可视化。您可以在roadtraffic.dft.gov.uk/downloads找到它们。

9.3 收尾思考:时间序列

如果你想了解更多关于时间序列数据的信息,你可以针对两个方面的数据。第一个是熟悉你选择的工具箱中的时间序列工具。在 Python 中,这可能意味着理解pandas库中的各种日期和时间相关数据类型,或者探索statsmodels中的某些统计方法。了解你的工具箱中日期和时间的表示方式对于成功操作这类数据至关重要。例如,你会在某个时候遇到跨越多个时区的时间数据。这是处理时间数据的一个复杂且令人沮丧的方面,而且提前做好准备是很有用的。

另一个你可以采取的深入学习角度是深入研究预测。现在有许多复杂的算法可以进行准确的预测,但我的建议通常是先从简单开始。时间序列预测的方法在计量经济学等领域已经存在了几十年,甚至在“数据科学”这个短语出现之前。为此,我通常推荐 Hyndman 和 Athanasopoulos(2021)所著的《预测:原理与实践》一书,可在otexts.com/fpp3免费获取。这是一本优秀的资源,可以引导你了解预测的基础知识,其中包含 R 语言的代码示例。Python 的翻译版本也可用,例如github.com/zgana/fpp3-python-readalong

只有在了解了这类入门材料之后,我才推荐深入研究更复杂、更深入的工具,例如 Facebook 的 Prophet 库(facebook.github.io/prophet)。最终,这是在实践中尝试预测时间序列时你会转向的工具,但了解基础知识会使你更容易根据具体问题调整你的方法,并理解为什么你的预测有时会出错。

9.3.1 适用于任何项目的时间序列数据处理技能

让我们回顾探索、操作和分析时间序列数据所需的能力。这些能力适用于任何时间序列数据项目,包括

  • 调查时间序列数据以检查完整性(例如,时间序列是在不同位置测量的,还是所有位置都有相同数量的数据?)

  • 确定时间序列的粒度(即,它是按小时、每日还是每周?数据中实际上是否存在多个时间序列,例如,在不同的位置?)

  • 理解数据的覆盖范围(即,数据覆盖了哪个时间段?)

  • 调查时间序列是否存在缺失值

  • 调整时间序列数据以具有不同的粒度级别(例如,将每小时数据汇总到每日级别)

  • 使用适当的图表(即,通常是折线图)可视化时间序列

  • 计算重复测量的分布(例如,“每小时看到的自行车数量”,典型的每小时计数是多少?)

  • 深入到单个数据点级别以调查异常

  • 将时间序列分解以确定它是否有趋势或季节性

  • 在时间序列中识别时间模式(例如,早上交通流量高的循环位置)

  • 根据多个标准查找感兴趣的时间序列

  • 将时间序列预测到未来以预测未来趋势

摘要

  • 时间序列分析可以揭示时间模式,例如活动高峰时间或增长区域。

  • 时间序列可以分解以分别研究趋势和季节性行为。

  • 预测时间序列依赖于有足够的历史数据点且没有缺失值。

  • 您可以通过构建和评估一个预测模型来确定一个时间序列是否可以被成功预测。

第十章:10 快速原型:数据分析

本章涵盖

  • 快速原型化想法以支持商业案例

  • 探索数据集以构建概念验证

有时,数据分析是关于调查一个想法是否可行。例如,现有数据是否足够让公司构建一个数据驱动的应用程序?我们可以通过分析数据来回答这样的问题,但如果我们能构建一个可工作的概念验证,那就更有力量了。通过这样做,我们让我们的利益相关者看到这个想法的可行性。关键的是,我们也会发现使用数据实现这一目的的任何障碍。

真实商业案例:构建概念验证

本章的灵感来源于我作为数据科学家交付的第一个项目之一。数据团队的任务是创建一个盈利产品。想法是一个应用程序,允许用户输入车辆的注册信息,并显示该车辆及其类似车型的当前市场状况。定义什么使一辆车足够相似是难点,而这正是产品背后的数据驱动秘密。一旦我们确立了某些规则,我就构建了一个快速的概念验证,向利益相关者展示这个想法是可行的。构建这个概念验证也意味着我可以传达构建真实产品的具体挑战,因为我实际上已经尝试过使用可用的数据和系统使其工作。

在本章和下一章的项目中,我们通过探索一个新的数据集并构建一个概念验证来练习快速原型的技能。

10.1 快速原型制作过程

数据团队通常位于研发部门,或者作为他们工作的一部分被期望进行研发。这意味着他们不仅分析数据来回答问题,有时还会构建公司目前不存在的物品。对于分析师来说,一项宝贵且经常被忽视的技能是能够将某些功能组合在一起,以调查一个想法的可行性。这个“某些东西”通常被称为概念验证(POC)、原型最小可行产品(MVP),尽管 MVP 通常比原型更进阶。

备注:本领域的术语往往有所不同。在本章中,我将使用“概念验证”和“原型”的具体定义。项目任务将是构建一个概念验证,但在章节标题中,构建某物以测试想法的更一般过程被称为“快速原型”。

虽然这些术语往往可以互换使用,但它们之间有细微的差别:

  • 概念验证是测试一个想法的东西。它不一定要是一个精良的产品;它只需要足够复杂,以便可以看到一个想法是否可行。

  • 原型从概念验证中演变而来。一旦一个想法经过测试,我们就可以基于概念验证和真实数据构建一个小型、可用的产品。它不会完成,它可以存在于像某人的笔记本电脑这样的开发环境中,并且它不会具备完全功能软件的所有功能,但它处于一个模仿最终产品的状态。

  • 一旦原型存在并且已经向利益相关者展示,可能会有一步创建 MVP。MVP 是一种试点发布,以便尽可能快地将产品推向真实用户。它可能缺少一些功能,但它处于生产环境中,并且已经应用了严谨性以确保其正确运行。

  • 无论我们是在谈论一个应用程序、一个网站还是机器学习模型,都会有一个将其投入生产的想法。生产意味着它在运行,并且人们在使用它,无论是内部用户还是外部客户。达到 MVP 阶段就相当于将其投入生产。

图 10.1 展示了这个过程的示例,包括一个想法可以在从最初的想法到构建工作原型之后的任何阶段被放弃的事实。

图

图 10.1 从想法到生产以及中间的步骤

让我们看看一个具体的例子。

10.1.1 快速原型示例

假设您的利益相关者想知道哪些客户可能从他们的电子商务平台流失。换句话说,哪些客户可能停止使用该服务?他们希望看到一些工具或仪表板中的风险客户列表,例如“早期预警系统”,以便他们可以采取行动并防止这些用户流失。

作为一名分析师,你可能会构建一个模型来预测处于流失风险的用户,并将其整合到这样的交互式工具中。表 10.1 显示了该产品生命周期的三个阶段。

表 10.1 三阶段示例遍历
阶段 任务 成功标准 在此阶段放弃的可能原因

| 1) 概念验证 | 构建一个基本的预测模型。 | • 基本模型已构建到可接受的准确度水平。 • 可用数据足以构建模型。

• 可用数据甚至不足以构建一个基本的流失模型。

| 2) 原型 | 构建一个可工作的交互式工具。 | • 用户可以使用该工具采取预防措施,以保持处于风险中的客户。 • 可以及时做出预测。

| • 预测所需数据在预测时不可用。 • 产品没有市场。

|

| 3) 生产 | 将工具构建为具有工作数据管道的完全功能软件。 | • 预防措施产生了可衡量的财务影响。 | • 投资回报率不足。 • 工具使用率低。

|

为什么在开始时进行概念验证如此重要?我们希望尽快理解以下内容:

  • 我们是否有数据来回答问题、构建预测模型和创建我们的应用程序?

  • 产品上线后,我们是否会在需要时拥有数据?许多机器学习项目都失败了,因为虽然历史数据可用于训练模型,但新数据在模型做出有用预测的时间点不可用。

  • 建立这个产品的挑战有哪些?这些可能和数据可用性有关,但也可能涉及其他无数的技术挑战。关键在于,直到你真正尝试去建立它,你才知道所有建立某物的挑战。

原型阶段与概念验证的区别在于我们现在有不同的问题:

  • 有人对这个产品感兴趣吗? 这个产品不必存在就能回答这个问题——一个原型就足够了。即使是一个在公司内部部署的仪表板或机器学习模型,也需要有产品市场匹配度。

  • 真实用户会如何使用这个产品? 通过一个原型,你可以在不构建整个产品的情况下测试你对产品真实用户如何使用它的假设。这个过程还处于早期阶段,因此在这个阶段取消项目不应该花费太多。

这些概念对软件开发人员和产品经理来说是熟悉的,但它们对分析师也有价值。能够构建一个概念验证和随后的原型帮助分析师确定值得工作的内容,并更快地迭代想法,这反过来意味着更注重结果的工作方式。

现在我们将这些想法付诸实践。项目概述不仅仅是分析数据来回答问题,它还涉及到构建一些东西来评估数据是否能够支持一个付费产品。

10.2 项目 7:构建一个概念验证来调查威尔士房地产价格

让我们来看看这个项目,我们将不仅分析一些数据,还要构建一个概念验证数据产品向利益相关者展示。我们将从查看问题陈述、数据字典、我们追求的输出以及我们需要解决这个问题的工具开始。我们将比平时花更多的时间思考输出,因为我们不仅超越了分析,还进入了创造产品的阶段。然后我们可以制定一个以结果为导向的行动计划,并深入到示例解决方案中。

10.2.1 问题陈述

你在为 CymruHomes Connect 工作,这是一家专注于威尔士房产的房地产公司。他们希望通过数据来扩大业务;他们希望以新应用程序的形式向客户提供关于威尔士房地产市场的见解。这个应用程序将使用历史房产销售数据,使用户能够探索他们感兴趣地区的房产价格。

他们发现英国政府的土地注册处有一个名为“成交价格”的数据集,其中包含公开的历史销售数据。他们提取并提供了几年的这些数据。

注意:原始数据来自 mng.bz/yWvB。它包含 HM 土地登记局数据 © 英国皇家版权和数据库权利 2021。此数据根据开放政府许可 v3.0 许可。感谢土地登记局和英国皇家邮政分别允许使用房价和地址数据。

他们要求你调查这些数据是否确实适合为他们的新应用程序提供动力。这相当模糊,但他们有一些想法希望融入:

  • 他们特别关注房产类型的分析,例如,一栋房子是连排式还是独立式,因为他们坚信这强烈影响了他们的客户在选择房产时的决策,所以他们希望分析包括这一维度的细分。

  • 他们还认为用户会对比较最细粒度的房产感兴趣,因此查看街级数据的能力很重要。

他们只要求你探索数据并看看可以用它回答哪些问题,重点关注他们之前提到的角度。然而,我们将超越他们的要求,构建一个概念证明来展示最终应用程序可能的样子和功能。

我从数据科学工作中学到的一个重要教训是,利益相关者往往无法明确表达他们想要什么,因为他们缺乏对可能性的了解。创建概念证明是帮助弥合这一差距并作为数据专业人士提供额外价值的一种方式。

在我们讨论在过程中可能采取的步骤之前,让我们回顾一下可用的数据、期望的结果以及我们将需要的工具。

10.2.2 数据字典

和往常一样,一个关键的第一步是查看可用的数据。表 10.2 显示了土地登记局提供的数据字典。数据字典的一部分来自 Kaggle (mng.bz/QDWG),地址列在此处详细说明:mng.bz/XxWv。原始数据字典的定义提供如下。

表 10.2 价格支付数据的数据字典
定义
交易唯一标识符 自动生成的参考编号,记录每次发布的销售。该编号是唯一的,每次记录销售时都会更改。
价格 转让文件上声明的销售价格。
转让日期 销售完成的日期,如转让文件上所述。
邮编 地址的邮政编码。
房产类型 D = 独立式,S = 半独立式,T = 连排式,F = 公寓/联排别墅,O = 其他

| 旧/新 | 表示房产的年龄,并适用于所有价格支付交易,住宅和非住宅。Y = 新建房产,N = 已建立的住宅建筑

|

持有时间 与产权相关:F = 自由持有,L = 租赁持有
主要地址对象名称(PAON) 通常为门牌号/名称(例如,42 或“橡树小屋”)。
第二个可寻址对象名称(SAON) 如果有子建筑,例如,建筑物被分割成公寓,将会有一个 SAON。
街道 地址中的街道部分。
地区 关于位置的额外详细信息(例如,城市中的区域)。
城镇/城市 地址中的城镇/城市部分。
区域 地址中的区域部分。
地址中的县部分。

| 类别类型 | 表示支付价格交易的类型。A = 标准支付价格条目;包括以全市场价值出售的单套住宅。

B = 额外支付价格条目;包括在出售/收回权下的转让、出租购房(如果可以通过抵押贷款识别)以及转让给非私人个人。

|

记录状态 仅与月度文件相关。表示记录的增加、更改和删除。年度文件包含所有记录的最新版本。

现在我们已经看到了可用的内容,让我们看看这个项目的成果。

10.2.3 预期成果

我们的利益相关者最初希望了解我们可以在潜在应用程序中包含哪些类型的分析。关于要纳入的额外数据源的建议也将很有用。最后,我们决定构建一个概念验证,部分是为了向利益相关者展示他们的潜在应用程序可能的样子,同时也是为了测试数据是否足以构建一个有用的产品。

10.2.4 必需工具

证明概念的内容将取决于您首选的工具。它可能是一个工作网络应用程序,例如示例解决方案中用 Python 库streamlit构建的应用程序。然而,它也可能是一个仪表板,例如用 R 语言的包 Shiny 或使用商业智能工具如 Tableau 或 Power BI 构建的仪表板。

在示例解决方案中,我使用 Python 和pandas库进行数据探索,以及matplotlibseabornridgeplot进行可视化。我还介绍了streamlit库来构建交互式、基于网络的证明概念。您的工具可能不同,尤其是在概念验证阶段。此项目的清单是您的工具可以

  • 从包含数百万行数据的 CSV 文件中加载和组合大型数据集

  • 执行基本的数据操作任务,例如过滤、排序、分组和重塑数据

  • 创建数据可视化

  • 创建一个交互式应用程序,无论是仪表板还是网络应用程序,其中根据用户输入显示不同的工件,例如图表或其他可视化。

小贴士 您可能发现使用两个不同的工具来完成此项目更容易:一个用于数据分析,另一个用于构建概念验证。如果您使用包含用于构建应用程序的库或包的编程语言,例如 R 语言的 Shiny 或 Python 的streamlit,您可能想使用此项目来尝试它们。

10.3 将结果驱动方法应用于调查威尔士房产数据

让我们现在用我们的以结果为导向的框架来解决这个问题,并制定我们的行动计划。

figure

我们对利益相关者想要看到的内容有一个想法。他们的兴趣在于数据是否适合支持他们拥有的应用想法。他们表示,他们特别感兴趣的是房产类型以及是否可以将街级数据纳入他们的应用中。我们也理解,构建概念验证将使我们能够识别使用这些数据可能存在的问题,这就是为什么我们将花时间这样做。

figure

从最后开始意味着首先考虑应用。在我们分析数据之前,我们无法确切知道应用中会有什么,但在分析阶段关注应用将帮助我们更快地获得结果。在分析阶段创建图表时,我们应该考虑它们是否对概念验证有用。因此,我们的分析不仅会考虑利益相关者的要求,还会考虑未来应用用户的可能偏好。在这个项目中,我们的最小可行答案将更类似于如果我们设计软件时的最小可行产品,这在某种程度上是我们正在做的事情。

figure

在这个例子中,数据已经为我们识别出来。然而,在构建我们的概念验证时,我们可能会发现数据中的空白或该数据未涵盖的房地产市场方面。我们应该考虑额外的数据来源,以改善应用的质量,并在向利益相关者传达结果时推荐它们。

figure

我们已经从土地登记处下载了原始数据。然而,在我们开始探索数据之前,我们需要将不同的年度文件合并。我们也可能希望花些时间查看数据的来源,即土地登记处的网站,以了解更多关于数据是如何收集的以及可能存在的局限性。

figure

我们将分两个阶段进行项目工作。第一阶段是分析可用数据,因此我们的高级步骤可能是

  • 调查数据的完整性,例如识别缺失数据。

  • 理解数据的地理分布,例如不同的地址级别,如地区和区。

  • 一旦我们了解了地理分布,我们就可以提取威尔士的房产数据用于我们的应用。

  • 我们还希望根据利益相关者的要求调查房产类型。我们可能感兴趣的问题如下:销售价格如何随房产类型而变化?哪些房产类型更受欢迎?这些价格和流行模式在地理上是否有所不同?

  • 最后,我们希望识别出可以在我们的概念验证应用中重新创建的可视化。

一旦我们完成了初步分析,我们就可以着手构建概念验证。这一阶段我们需要考虑的,以及因此对概念验证应用程序的要求如下:

  • 应用程序中显示的任何数据和可视化应根据用户输入进行更改。

  • 应用程序应使用所有真实、可用的威尔士房地产数据。

  • 用户可以更改的选项应来自数据(例如,他们可以从中选择的县列表)。

概念验证不需要是一个网络应用程序;它只需要包含基于真实数据的交互元素,这样你就可以向利益相关者展示。在 BI 工具中构建的仪表板将满足这些标准。如果你是 Python 和 Jupyter 用户,甚至在 Jupyter 笔记本中拥有交互式小部件也足够了。

figure

输出的展示将包括传达分析阶段的结果,以及让利益相关者看到甚至尝试交互式概念验证。这两者相辅相成,因为概念验证的任何局限性都将在分析阶段被发现,并在展示概念验证本身时进行传达。

figure

在快速原型设计时,迭代至关重要。我们用于构建概念验证的任何工具都应该使我们能够轻松快速地对功能进行大量更改。在这个阶段,我们不受生产考虑的限制,如用户身份验证、权限或管理服务器和数据库。我们应该能够根据概念验证和原型阶段的反馈快速做出更改。

既然我们已经概述了行动计划,现在是时候开始着手项目了。如果你是从头到尾阅读这一章,我建议在阅读下一节之前,尝试自己完成这个项目,该节详细介绍了示例解决方案。

10.4 一个示例解决方案:构建原型以探索使用房价数据

现在,让我们通过一个示例解决方案来探讨。一如既往,我强烈建议你首先尝试自己完成这个项目,因为我们的解决方案会有所不同,尤其是对于这个项目。

至于行动计划,我们首先将合并单独的年度文件。然后,我们将探索合并后的数据集,特别关注数据中的物业类型和地理层次结构,正如利益相关者所要求的。在确定构建概念验证的适当工具之前,我们将决定哪些可视化将包含在我们的应用程序中。最后,我们将使用数据构建一个概念验证,用户可以根据他们的输入更改显示的可视化。

10.4.1 在原型设计前分析数据

我们流程的第一步是将利益相关者提供的不同年度文件合并。它们涵盖了从 2021 年到 2023 年 inclusive 的时期。最初,我们将假设它们具有相同的格式,但我们已准备好应对这种情况。

合并没有标题的数据集

让我们看看其中一个文件。以下代码生成了如图 10.2 所示的输出:

import pandas as pd

prices_2021 = pd.read_csv("./data/pp-2021.csv.gz", nrows=1000)
prices_2021.head()

图

图 10.2 2021 年原始数据的快照

检查后发现,文件中没有标题,因此我们需要根据数据字典提供列名。除此之外,我们还将日期列转换为正确的类型,以便我们可以验证数据确实覆盖了它所声称的年份。以下代码按顺序读取每年的每个文件并将它们合并成一个单一的pandas DataFrame。合并数据的快照输出如图 10.3 所示。

annual_dfs = []

for year in [2021, 2022, 2023]:
    print(f"Parsing {year}")
    df = pd.read_csv(
        f"./data/pp-{year}.csv.gz",
        names=["transaction_id", "sale_price", "sale_date", "postcode",
               "property_type", "old_new", "duration", "house_number_name",
               "second_addressable_object_name", "street", "locality",
               "town_city", "district", "county",
               "category_type", "record_status"],
        parse_dates=["sale_date"])
    annual_dfs.append(df)

price_paid = pd.concat(annual_dfs, axis=0, ignore_index=True)
print(price_paid.shape)
price_paid.head()

图

图 10.3 合并后的价格数据快照

让我们验证这个合并后的数据集是否从 2021 年开始,并在 2023 年底结束。我们将查看最小和最大的日期来完成这项工作,如下面的代码所示,其输出结果如图 10.4 所示:

price_paid["sale_date"].agg(["min", "max"])

图

图 10.4 验证数据确实覆盖了 2021-2023 年

现在我们已经合并了数据并验证了日期范围,我们将导出它以供以后使用。这意味着我们的分析和概念验证代码可以直接引用合并后的数据:

price_paid.to_csv("./data/price_paid.csv.gz", index=False)

我们现在可以进入分析阶段了。

调查数据质量

让我们对合并后的数据进行一些初步的合理性检查。我们感兴趣的是查看缺失数据、异常值,尤其是在销售价格方面,以及各种类别的分解。我们将首先再次读取数据。如果我们从之前的代码片段继续,这并不是严格必要的,但因为我们决定将合并和数据分析代码分开是一种良好的实践,我们将这样编写代码。在示例代码文件中,合并和分析阶段发生在不同的文件中,因此我们也将保持一致性:

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns

pd.options.display.float_format = '{:.2f}'.format

price_paid = pd.read_csv("./data/price_paid.csv.gz",
                         parse_dates=["sale_date"])
print(price_paid.shape)
price_paid.head()

在这里,我们还设置了一个名为“浮点格式”的东西。这是告诉pandas如何显示数字的一种方式。我们明确设置它的原因是因为房价会有很大的变化,pandas可能会使用科学记数法显示最大的数字。也就是说,一百万可能会显示为1e6或类似,而不是显示所有的零。设置浮点格式将避免这个问题,并且由于我们将处理的是货币价值的价格,两位小数是有意义的。

让我们先看看我们列中的缺失数据。以下代码调查了这个问题,输出结果如图 10.5 所示:

price_paid.isnull().sum()

图

图 10.5 每列缺失值的数量

这个输出告诉我们,所有缺失数据都与地址有关。我们有一些缺失的邮政编码、街道名称,以及大量缺失的本地性和“第二个可寻址对象名称”(SAON)数据。这些后列将针对不同的地区和财产,因此它们不太可能成为问题。数据字典甚至建议并非所有财产都有 SAON。

我们应该检查那些没有街道名称的属性实例,因为这可能是一个问题,特别是考虑到我们的利益相关者询问了街道级数据。以下代码调查了一些缺失的街道名称,输出结果如图 10.6 所示:

price_paid.loc[price_paid["street"].isnull(),
               ["house_number_name", "second_addressable_object_name",
                "street", "postcode", "locality", "town_city",
                "district", "county"]]

figure

图 10.6 没有街道详细信息的属性样本

虽然这只是超过 50,000 行缺失街道名称的小部分,但似乎很多这些实例都是像“The Barn”或“The Old School”这样的属性名称。这或许可以解释为什么地址的一部分缺失。

我们可以保留这些缺失的街道名称,因为在许多情况下,它们似乎是不适用而不是缺失。然而,如果我们要在我们的应用程序中向用户展示街道名称,我们需要一个选项来过滤没有街道名称的属性。我们可以在应用程序本身中处理这个问题,或者用占位符值填充缺失的数据。让我们选择使数据尽可能干净,并填充缺失值。以下代码实现了这一点:

STREET_PLACEHOLDER = "-- NO STREET INFORMATION --"
price_paid["street"] = price_paid["street"].fillna(STREET_PLACEHOLDER)

让我们总结一下到目前为止我们所做的工作,并开始构建我们的图来记录我们的步骤。图 10.7 显示了到目前为止我们所做的工作,包括关于如何处理缺失街道名称的决策。

figure

图 10.7 我们分析的第一步

调查缺失的地理数据

比缺少街道名称更令人担忧的是,我们竟然有数千个缺失的邮政编码。再次,让我们看看这些缺失邮政编码的几个例子。以下代码找到了一些示例,输出结果如图 10.8 所示:

price_paid.loc[price_paid["postcode"].isnull(),
               ["house_number_name", "second_addressable_object_name",
                "street", "postcode", "locality", "town_city",
                "district", "county"]]

figure

图 10.8 缺失邮政编码数据的记录选择

观察房屋名称,这些名称似乎也是具有明确名称的属性,以及一些关于车库的引用。然而,在这种情况下,这并不能解释为什么没有邮政编码信息,因为英国地址通常都有邮政编码。我们关于这些缺失数据有一个决策要做出。一方面,缺失的记录只占整体数据集的一小部分,但另一方面,这些记录仍然对房地产市场有贡献,因此它们都包含相关信息。

我们将记录没有邮政编码信息的记录,但在继续我们的分析时,我们会留意它们的存在。既然我们已经达到了另一个决策点,让我们将这一步添加到图 10.9 中的图中,以记录我们分析中的最新步骤。

figure

图 10.9 我们分析步骤的最新图

调查价格列中的分布和异常值

我们也对价格列的分布感兴趣,因为它是我们应用程序将基于的关键变量。以下代码生成了数据的概要统计摘要,如图 10.10 所示:

price_paid["sale_price"].describe()

figure

图 10.10 sale_price列的统计概述

存在的价值从£1 到超过£500 百万不等,因此将会有异常值需要处理。平均值也显著高于中位数,这在图表中显示为 50%,表明数据是右偏斜的。这本身并不可怕,因为价格数据往往呈现这种形状,但我们将调查异常值。首先,让我们看看售价低于£1,000 的房产。以下代码应用了这个过滤器,输出结果如图 10.11 所示:

(
    price_paid.loc[price_paid["sale_price"] < 1000,
    ["sale_price", "house_number_name",
     "street", "town_city", "postcode",
     "district", "county", "category_type"]
    ]
    .sample(10, random_state=42)
)

figure

图 10.11 10 个售价低于£1,000 的房产样本

观察这些交易的地址,似乎没有其他数据错误可以解释这些低售价。然而,根据category_type列,所有这些交易都属于类别 B。参考表 8.1 中的数据字典,我们可以看到类别 B 记录是非标准交易,包括“在出售权/收回下的转让”。似乎属于 B 类别的房产以象征性的金额出售,原因并非标准房产购买。为了验证这一点,让我们看看低价值房产中 A 类与 B 类的比例。以下代码执行此操作,并在图 10.12 中产生输出:

(
    price_paid.loc[price_paid["sale_price"] < 10_000,
                   "category_type"]
    .value_counts()
)

figure

图 10.12 低价值房产的category_type分布

这为我们数据中的最低值提供了一个解释。还有超过£500M 范围的房产,如图 10.10 中的表格所示。让我们检查这些房产以了解更多信息。以下代码识别这些高价值房产,输出结果如图 10.13 所示:

price_paid[price_paid["sale_price"] > 300_000_000]

figure

图 10.13 值超过£300M 的房产

根据名称,其中之一是一个完整的科学园区,经过一番研究,最昂贵的交易可能是一种被称为 Nine Elms Park 的东西,这是一个位于伦敦 Nine Elms Lane 开发中的大型绿地。

为了总结我们对价格列的分析,低价值交易是由于特殊类型,即类别 B,而最高价值交易是整个开发项目而不是单个房产。我们不希望在我们的应用程序中包含这些高价值交易,该应用程序旨在面向住宅买家,因此我们将删除最高值。我们也可以考虑删除类别 B 交易,但也许应用程序的用户可能对感兴趣地区的收回或其他非标准交易感兴趣,所以我们将保留这些数据。以下代码仅保留低于高阈值的 数据,例如£10M,这对于大多数住宅买家来说是一个合理的截止点。任何预算更高的买家将更适合由更专业的房地产机构服务:

price_paid = price_paid[price_paid["sale_price"] < 10_000_000]

由于我们必须对异常值做出决定,让我们在记录到目前为止过程的图表中添加另一个步骤,如图 10.14 所示。

figure

图 10.14 包括价格异常调查的最新流程图

现在,我们已经准备好查看数据的其他方面,即我们房产交易的多种分类。

调查数据中的分类

在我们的数据中,房产的分类有几种不同的方式。根据表 8.1 中的数据字典,我们可能想要更新这些类别的名称,使其更具描述性。让我们依次查看它们。

房产类型对我们利益相关者来说非常重要。这些数据用单个字母标记(例如,T 代表排屋)。这没问题,但如果我们想在我们的应用程序中使用这些数据或正确标记图表,完整的名称会更好。以下代码将房产类型重新映射到它们的完整名称,然后创建图 10.15 所示的图表,该图表显示了我们的数据中房产类型的细分:

property_type_map = {
    "D": "Detached",
    "S": "Semi-Detached",
    "T": "Terraced",
    "F": "Flats",
    "O": "Other"
}

price_paid["property_type"] = (
    price_paid["property_type"]
    .map(property_type_map)
)

fig, axis = plt.subplots()

(
    price_paid["property_type"]
    .value_counts()
    .sort_values()
    .plot
    .barh(ax=axis)
)

axis.set(
    title="Distribution of property type",
    xlabel="Count",
    ylabel="Property type"
)

plt.show()

figure

图 10.15 我们数据中房产类型的细分

如果你已经看到过英国的房子通常是什么样的,那么看到大多数房产要么是排屋要么是半独立式,就不会感到惊讶。现在,让我们重新映射其他分类列,从房产是否为新建开始,即在购买时新建。以下代码重新分类了数据,并在图 10.16 中产生了输出,以调查这一列的细分:

price_paid["old_new"] = (
    price_paid["old_new"]
    .map(
        {
            "Y": "New build",
            "N": "Existing property"
        }
    )
)
price_paid["old_new"].value_counts()

figure

图 10.16 现有房产与新建房产的细分

这一列的合理性检查应该是大多数属性在购买时已经建成,这正是我们在数据中看到的情况。现在让我们更新duration列,这一列区分了自用房产和租赁房产。租赁意味着你并不拥有你结构所在的土地;如果租赁即将到期,你需要支付额外费用并续签租赁。英国的大多数房产应该是自用房产。让我们在重新分类数据的同时验证这一点。以下代码执行了这一操作,输出结果如图 10.17 所示:

price_paid["duration"] = (
    price_paid["duration"]
    .map(
        {
            "F": "Freehold",
            "L": "Leasehold"
        }
    )
)

price_paid["duration"].value_counts()

figure

图 10.17 自用房产与租赁房产的细分

现在我们已经调查了大部分的房产数据,我们应该将注意力转向我们尚未触及的方面:地理。

10.4.2 调查数据集的地理方面

我们的利益相关者只对威尔士的房产市场感兴趣,因此在我们构建我们的概念证明之前,我们需要提取仅在威尔士发生的交易。这需要以某种方式提取这些信息,因为我们没有明显的国家列。有关从地址中提取信息的更多实践,请参阅第二章中的相关项目。

每当我们处理地址时,我们都有几种选项来提取额外信息:

  • 理想情况下,有一个列已经存储了必要的信息。在这种情况下,并没有。

  • 如果我们有一个组合的地址字段,例如第二章中的项目,我们可以提取正确的地址组件。在这个例子中我们也没有。

  • 我们可以使用现有的列与官方列表进行交叉引用,类似于我们在第二章示例解决方案中所做的,在那里我们将地址数据与英国城市的确切列表进行交叉引用。在这个项目中,我们也可以交叉引用地址分类之一,找到对应于威尔士的地址。

  • 一种更复杂但可能更准确的方法是使用第三方地理编码服务来获取我们地址数据的结构化版本。对于这个项目中的数据,我们可以将所有地址列的组合发送给该服务,并得到以标准化格式返回的相同地址,这将包括国家列。

在这些选项中,第三个似乎在准确性和努力之间找到了平衡。最后一个选项是我们想要最大化准确性时可以考虑的,但只有当我们确定额外的工作是值得的。现在让我们调查数据中的不同地址分类,看看哪一个可以与相关的官方列表进行交叉引用。

一个选项是查看town_city列,并将其与威尔士城市列表进行交叉引用。运行以下命令显示,城镇/城市组件有 1,150 个唯一值。我们还将该列转换为大写,以防数据中存在一些不一致的大小写:

price_paid["town_city"] = price_paid["town_city"].str.upper()
price_paid["town_city"].nunique()

有这么多值增加了出错的可能性。可能会有拼写错误和其他重复值,还有一些城镇名称在英格兰和威尔士都存在,例如纽波特。让我们看看更高一级的地理层次:县。下面的代码调查了数据中存在的县的频率,同时也揭示了唯一项目的数量,如图 10.18 所示:

price_paid["county"] = price_paid["county"].str.upper()
price_paid["county"].value_counts()

figure

图 10.18 按县记录数量的分解

从这个输出中,我们可以看出数据中有 115 个独特的县值,大伦敦是最常见的。最频繁的记录告诉我们,每个县的记录数在数万左右,这使得一些最不常见的值显得可疑。北安普顿郡不是一个小县,所以我们预计会有超过 707 条记录。也许有一些拼写错误或重叠的县值。我们将通过仅查找单词 NORTH 来调查这些情况,看看会返回什么。下面的代码就是这样做的,输出显示在图 10.19 中:

(
    price_paid
    .loc[price_paid["county"].str.contains("NORTH"), "county"]
    .unique()
)

figure

图 10.19 包含单词 NORTH 的所有县

我们发现北北安普顿郡和西北安普顿郡也有记录。如果我们对英格兰的县感兴趣,我们可能会考虑将它们合并。这份数据可能并不完全符合官方列表,但清理 115 个县记录比清理 1000 多个镇名要容易。

从外部来源对我们的数据进行合理性检查

目前,我们想要一个威尔士的官方县列表,以便与我们的数据进行交叉引用。威尔士政府已将此列表发布在其网站上:law.gov.wales/local-government-bodies

备注:如果网站无法访问,本章补充材料中包含其相关内容的副本。文件名为wales-local-government-bodies.htm。它位于data文件夹中,可以在任何网络浏览器中查看。

列表在页面中间大约一半的位置以项目符号形式列出,如图 10.20 所示。

figure

图 10.20 威尔士官方县列表

从这个页面,我们可以提取县名的唯一名称。我们的数据是英文,不是威尔士文,所以我们只需要这个列表的英文部分。我们也不需要“县议会”部分,因为这些短语不包含在我们数据的county列中。我们将取这个县列表,并假设任何包含这些县之一的记录是威尔士的财产,其余的都在英格兰。以下代码根据县将记录分类为英格兰或威尔士。然后我们将查看这个新的country列的分布,如图 10.21 所示:

welsh_councils = [
    c.upper() for c in ["Blaenau Gwent", "Bridgend", "Caerphilly",
                        "Cardiff", "Carmarthenshire", "Ceredigion",
                        "Conwy", "Denbighshire", "Flintshire",
                        "Gwynedd", "Isle of Anglesey", "Merthyr Tydfil",
                        "Monmouthshire", "Neath Port Talbot", "Newport",
                        "Pembrokeshire", "Powys", "Rhondda Cynon Taf", "Swansea", "The Vale of Glamorgan", "Torfaen",
                        "Wrexham"]
]

price_paid["country"] = (
    np.where(
        price_paid["county"].isin(welsh_councils),
        "WALES",
        "ENGLAND"
    )
)

price_paid["country"].value_counts(dropna=False)

figure

图 10.21 我们数据中英格兰与威尔士的细分

如我们之前所说,county列中的一些值可能并不完全符合威尔士县的列表。我们应该使用我们的方法查看所有被分类为英格兰的县,并检查是否有任何是威尔士县的误拼。以下代码检索了这些县,如图 10.22 所示:

print(
    sorted(
        price_paid
        .loc[price_paid["country"] == "ENGLAND", "county"]
        .unique()
    )
)

figure

图 10.22 我们将其归类为英格兰的县列表

我们可以采用更复杂的方法来找到这个列表中几乎像威尔士县之一的县名,但由于总的县数量并不多,我们可以手动完成。参见第三章中的示例解决方案,了解如何进行更复杂的字符串匹配。

乍一看,似乎只有 Rhondda Cynon Taf 这个县被错误分类了,它在数据中末尾有两个 f。让我们手动将这些实例重新分类为威尔士,以使数据更准确:

price_paid.loc[price_paid["county"] == "RHONDDA CYNON TAFF", "country"]
↪ = "WALES"

在本部分的最后一步,我们将仅将威尔士的属性提取到它们自己的 DataFrame 中,这样我们的所有分析都将仅限于威尔士的属性:

wales = price_paid[price_paid["country"] == "WALES"].copy()

下一步是探索这个威尔士交易的子集,看看数据中哪些方面应该包含在我们的概念验证应用程序中。但在我们继续之前,让我们以图表的形式回顾到目前为止的进展,如图 10.23 所示。然后,我们将准备好探索威尔士物业数据,以确定我们想要包含在概念验证应用程序中的可视化。

figure

图 10.23 确定威尔士物业的过程

10.4.3 在原型中确定如何呈现数据

现在我们已经按国家分离开了数据,让我们探索威尔士房地产市场不同的方面。

调查地理差异的变化

首先,让我们看看各县之间的销售价格差异。当用户想要购买物业时,应用程序应该帮助他们识别他们想要区域的物业价格。该图表显示在图 10.24 中,图后的代码计算并绘制了威尔士物业的按县的中位销售价格。

figure

图 10.24 威尔士按县的中位物业价格
fig, axis = plt.subplots(figsize=(6, 12))

(
    wales
    .groupby("county")
    ["sale_price"]
    .median()
    .sort_values()
    .plot
    .barh(ax=axis)
)

axis.set(
    title="Median sale price by county (Wales)",
    xlabel="Median sale price (£)",
    ylabel="County"
)

for label in axis.get_yticklabels():
    label.set_fontsize(8)

plt.show()

在各县之间存在明显的地理差异。这些数据覆盖了多年,因此我们还应该调查其时间方面。交易数量是如何随时间变化的?以下代码创建了图 10.25 中的图表:

wales["year"] = wales["sale_date"].dt.year

fig, axis = plt.subplots()

(
    wales
    .set_index("sale_date")
    .resample("YS")
    .size()
    .plot(ax=axis, color="gray")
)

axis.set(
    title="Transactions per year",
    xlabel="Year",
    ylabel="# of transactions",
    ylim=(0, 70_000)
)

plt.show()

figure

图 10.25 威尔士年度物业交易数量

交易数量有一个明显的整体下降趋势。这种模式是否也影响了价格?我们可以按县和年份计算中位销售价格来调查这一点。以下代码计算了这一点,并用图 10.26 中所示的热图进行可视化。

by_county_and_year = (
    wales
    .pivot_table(
        values="sale_price",
        index="county",
        columns="year",
        aggfunc="median"
    )
)

fig, axis = plt.subplots(figsize=(10, 10))

sns.heatmap(
    by_county_and_year / 1000,
    annot=True,
    cmap="Greys",
    fmt=".1f",
    ax=axis
)

axis.set(
    title="Median price by county and year (£ thousands)",
    xlabel="Year",
    ylabel="County"
)

plt.show()

figure

图 10.26 展示按县和年份划分的房价热图

在大多数县,我们观察到从 2021 年到 2022 年中位销售价格的增加,然后在 2023 年再次下降。尽管交易数量似乎稳步下降,但价格似乎只受到最新完整年份数据的影响。我们希望在最终应用程序中包含这方面的某些信息。

县之间差异的另一个方面可能是物业类型,这是我们利益相关者特别感兴趣的。图 10.27 之后的代码计算了按县和物业类型的中位价格,并在图中绘制了热图。

figure

图 10.27 展示了跨县和物业类型的房价热图
fig, axis = plt.subplots(figsize=(10, 10))

sns.heatmap(
    wales.pivot_table(
        index="county",
        columns="property_type",
        values="sale_price",
        aggfunc="median"
    ) / 1000,
    annot=True,
    cmap="Greys",
    fmt=".1f",
    ax=axis,
    annot_kws={"size": 8}      #1
)
axis.set(
    title="Median sale price (£ thousands) by county and property type",
    xlabel="Property type",
    ylabel="County"
)

plt.show()

1 降低坐标轴标签的字体大小

这个热图告诉我们,在所有县中,独立式物业的价值最高,但在“其他”类别中存在很高的变异性。这与英国房地产市场典型情况相符:独立式物业比半独立式物业更受欢迎,而半独立式物业又比排屋更受欢迎。这种模式似乎在各县都存在。

使用脊线图来查看组间的分布

这些热图没有告诉我们价格分布。我们可以使用直方图或箱线图按县或按年显示这种分布。然而,我们可能想要尝试一些更冒险的方法,使应用程序在视觉上更加突出。一个选项是所谓的脊线图,它看起来像按类别平滑的直方图,但直方图是相互堆叠的。为了更好地说明这一点,以下代码创建了一个按年价格分布的脊线图,如图 10.28 所示。数据需要是一个价格列表,每年一个列表。因为每年的交易数量不固定,我们不能使用表格数据结构,所以我们创建了三个不同长度的价格列表:

years = sorted(wales["year"].unique())
annual_sales = []

for year in years:      #1
    prices = (
        wales.loc[(wales["year"] == year)
                  & (wales["sale_price"] < 500_000),
        "sale_price"]
    )
    annual_sales.append([prices])     #2

from ridgeplot import ridgeplot      #3

fig = ridgeplot(annual_sales,
                labels=[str(y) for y in years],
                colorscale="gray_r")

fig.update_layout(
    title="Welsh property sale prices over time",
    xaxis_title="Price (£)",
    yaxis_title="Year",
    showlegend=False
)

fig.show()

1 创建一个按年价格列表,并将此列表收集到另一个列表中

2 annual_sales 将是一个包含 pandas Series 对象的列表。

3 脊线图模块专门用于这种类型的图表。

figure

图 10.28 显示随时间变化的房产价格脊线图

这个图表显示了随着时间的推移价格分布的变化,并通过在 3D 效果中重叠图表来节省空间。让我们通过按县来处理价格,以更好地展示价格的分布而不是简单的平均值。以下代码收集了按县的价格数据,并创建了相关的脊线图,如图 10.29 所示。这次,我们将去除时间维度,只关注 2023 年出售的房产,因此我们的图表尽可能最新和相关性最强:

counties = sorted(wales["county"].unique())
sales_by_county = []

for county in counties:
    prices = (
        wales
        .loc[(wales["county"] == county)
             & (wales["sale_price"] < 500_000)
             & (wales["year"] == 2023),
        "sale_price"]
    )
    sales_by_county.append([prices])

fig = ridgeplot(sales_by_county,
                labels=counties,
                colorscale="gray",
                coloralpha=0.9,
                colormode="mean-minmax",
                spacing=0.7)

fig.update_layout(
    title="Sale prices in Wales in 2023, by county",
    height=650,
    width=950,
    font_size=12,
    plot_bgcolor="rgb(245, 245, 245)",
    xaxis_gridcolor="white",
    yaxis_gridcolor="white",
    xaxis_gridwidth=2,
    yaxis_title="County",
    xaxis_title="Sale price (£)",
    showlegend=False
)

fig.show()

figure

图 10.29 按县价格分布的脊线图

这个图表比图 10.24 中的图表信息密度更高,图 10.24 只显示了中位数价格。我们将把这个图表包含在应用程序中作为参考,这样用户就可以对全国范围内的房产价格有一个概念。我们还将包括用户感兴趣的区域随时间变化的交易数量,以及按房产类型(如图 10.15 所示的图表)进行细分。这些可视化,加上我们计划添加的交互性,将形成我们这个项目的最小可行方案。

让我们回顾一下到目前为止的整个过程。图 10.30 显示了我们所做的一切,包括决定在应用程序中包含哪些可视化的最新步骤。

figure

图 10.30 展示了直到决定在概念验证中包含哪些可视化过程

除了我们选择包含的视觉化之外,我们还想在应用中添加一些交互性。我们将通过过滤器来实现这一点,以便用户可以过滤数据,仅显示他们感兴趣的区域,这将相应地更新图表。为此,我们需要了解我们将允许用户钻取到哪些粒度级别。我们的利益相关者要求如果可能的话,在应用中提供街道级别的信息,因此让我们调查是否可以使用现有数据实现这一点。

调查地理层级

数据中有几个列与不同的地址组成部分相关。为了设计我们应用的过滤器,我们需要了解它们的层级。根据对地址层级的了解,我们假设这个层级包括县、区、镇和街道。然而,“县”和“区”在不同的数据集中可能有不同的含义,因此我们将验证这个层级是否正确。为此,我们将计算每个类别的不同记录数量。不同值的数量越少,该类别在层级中的级别就越高。以下代码计算了这一点,并在图 10.31 中产生了结果。

hierarchy = ["county", "district", "town_city", "street"]
level_counts = []

for col in hierarchy:
    num_values = wales[col].nunique()
    level_counts.append(num_values)

for z in zip(hierarchy, level_counts):
    print(z)

图

图 10.31 每个地址组成部分的不同记录数量

有趣的是,似乎县和区之间存在一对一的映射。为了验证这一点,我们应该查看是否有任何县在数据中映射了多个区,反之亦然。我们通过以下代码实现了这一点。我们的假设是,如果这两行代码都返回 0 个结果,那么县和区之间就存在完美的一对一映射:

wales.groupby("county")["district"].nunique().loc[lambda x: x > 1]
wales.groupby("district")["county"].nunique().loc[lambda x: x > 1]

两个结果都返回了空值,这意味着没有区与多个县相关联,也没有县与多个区相关联。也许区列对英格兰是相关的,但对威尔士来说是多余的。因此,我们可以得出结论,我们的地址层级是县,然后是城镇和城市,最后是街道。因此,这些是我们将在应用中包含的三个过滤器。在我们开始构建应用之前,让我们将我们的威尔士属性作为一个单独的数据集导出,应用可以读取它。应用应该读取我们清理和过滤后的数据,并且不应该需要以任何方式对其进行操作。以下代码将数据导出到优化的 Parquet 格式:

wales.to_parquet("./data/wales.parquet", index=False)

这标志着项目的第一部分结束;我们已经准备好了数据,并准备好构建概念验证。在继续下一章构建概念验证之前,让我们回顾一下到目前为止我们所做的工作。

10.4.4 项目进展情况

到目前为止,在项目中,我们

  • 合并了多年的房产销售数据

  • 调查了数据质量,包括缺失值和异常值

  • 确定感兴趣的地理数据

  • 调查了销售价格列的分布和异常值

  • 通过外部政府数据增强了我们的地理数据,以区分英格兰和威尔士的房产交易

  • 为我们的概念验证确定了适当的可视化,包括脊线图

  • 导出了相关的、清洗过的威尔士房产交易数据,这些数据我们的概念验证将使用

在我们继续到最后部分之前,图 10.32 展示了我们到目前为止的进展。在下一章中,我们将继续进行项目的最后部分,即构建概念验证工具本身。

图

图 10.32 项目构建概念验证前的第一部分

摘要

  • 构建一个可工作的概念验证可能是识别数据中问题的最佳方式,这些问题可能会阻止数据用于特定目的。

  • 探索数据以构建概念验证包括评估数据是否适合该任务。

  • 分析的输出应该是一个清洗后的数据集,我们的概念验证可以直接使用。

第十一章:11 快速原型设计:创建概念验证

本章涵盖

  • 识别创建概念验证的工具

  • 制作一个概念验证来展示想法在实际操作中的现实性

在本章中,我们将使用我们在上一章中探索和导出的数据构建一个概念验证。在第十章中,我们确定了威尔士的物业交易,并将需要以交互式应用程序的形式呈现给最终用户。

数据可在davidasboth.com/book-code处供您尝试。您将找到可用于项目的文件,以及以 Jupyter 笔记本和 Python 脚本形式提供的示例解决方案。

我们已经探索了可用的数据,纠正了问题,并确定了将纳入概念验证的可视化。在本章中,我们将构建概念验证本身。首先,让我们回顾一下项目概述。

11.1 项目 7 回顾:构建一个概念验证来调查威尔士的物业价格

您正在为 CymruHomes Connect 工作,这是一家专注于威尔士房屋的物业公司。他们希望通过数据来扩大业务;他们希望以新应用程序的形式向客户提供关于威尔士物业市场的见解。该应用程序将使用历史物业销售数据,使用户能够探索他们感兴趣地区的物业价格。利益相关者有一些想法希望融入:

  • 他们特别关注物业类型的分析,也就是说,一栋房子是联排还是独立式,因为他们坚信这会强烈影响客户在选择物业时的决策。

  • 他们还认为用户会对在最低粒度级别比较物业感兴趣,因此查看街级数据的能力很重要。

让我们回顾一下我们正在处理的数据以及到目前为止所做的工作。

11.1.1 数据字典

我们的利益相关者发现,英国政府的土地登记处有一个名为“成交价格”的数据集,其中包含公开的历史销售数据。他们提取并提供了几年的这些数据。表 11.1 显示了数据字典,不是原始数据,而是我们在上一章末导出的数据集,这是我们概念验证将使用的数据。

注意:原始数据来自mng.bz/yWvB。它包含 HM 土地登记处的数据©皇家版权和数据库权利 2021。此数据根据开放政府许可 v3.0 许可。感谢土地登记处和英国皇家邮政分别允许使用房价和地址数据。

表 11.1 修改后的威尔士物业交易数据的数据字典
定义
transaction_id 自动生成的参考编号,记录每次发布的销售。该编号是唯一的,每次记录销售时都会更改。
sale_price 转让文件上声明的销售价格。
sale_date 销售完成日期,如转让文件上所述。
postcode 地址的邮政编码。
property_type D = 独立式,S = 半独立式,T = 连排式,F = 公寓/联排别墅,O = 其他
old_new 表示房产的年龄,并适用于所有已支付价格交易,包括住宅和非住宅。Y = 新建房产,N = 已建立的住宅建筑
duration 与租期相关:F = 永久产权,L = 出租产权
house_number_name 通常为房屋号码/名称(例如,42 或“橡树小屋”)。

|

second_addressable_
object_name
如果存在子建筑,例如,建筑物被划分为公寓,将会有一个 SAON。
street
locality
town_city
district
county

| category_type | 表示已支付价格交易的类型。A = 标准已支付价格条目;包括以全市场价值出售的单个住宅物业。

B = 额外已支付价格条目;包括在出售权/收回权下的转让、出租购房(如果可以通过抵押贷款识别)以及转让给非私人个人。

|

record_status 仅与月度文件相关。表示记录的增加、更改和删除。年度文件包含所有记录的最新版本。
country 交易的所在国家。可以是英格兰或威尔士,但导出的数据子集将全部为威尔士。
year 交易的年份。

11.1.2 预期成果

我们的利益相关者最初希望我们提供关于我们可以在潜在应用程序中包含哪些分析的建议。关于要纳入的额外数据源的建议也将很有用。最后,我们决定构建一个概念验证,部分是为了向利益相关者展示他们潜在应用程序可能的样子,同时也是为了测试数据是否足以构建一个有用的产品。

在继续之前,让我们回顾一下上一章的进展。

11.1.3 到目前为止的项目总结

在上一章中,我们

  • 合并了多年的房地产销售数据

  • 调查了数据的质量,包括缺失值和异常值

  • 确定了感兴趣的地域数据

  • 调查了销售价格列的分布和异常值

  • 使用外部政府数据增强了我们的地理数据,以区分英格兰和威尔士的房地产交易

  • 确定了适合我们概念验证的适当可视化,包括岭图

  • 导出了相关的、清洗过的威尔士房地产交易数据,这些数据将用于我们的概念验证。

图 11.1 显示了到目前为止的分析过程。

figure

图 11.1 到目前为止的项目进度

我们现在可以使用威尔士房地产交易的数据集来构建本章的概念验证。

11.2 构建概念验证

到目前为止,我们已经调查了可用的数据,提取了相关部分,并对其进行了探索,以了解需要在我们的概念验证中包含哪些方面。我们构建概念验证的行动计划如下:

  1. 选择一个快速原型工具—这个选择将取决于我们熟悉什么,以及在我们通常的工具包中有什么可用。

  2. 设计应用程序布局—在我们开始编写代码之前,我们需要计划应用程序的外观和它的工作方式。关注最终结果意味着我们最小化了在构建概念验证时需要进行的实验数量。

  3. 编写辅助函数以提取和过滤我们的数据—将数据从应用程序的交互部分抽象出来,将使得在需要时更改应用程序的前端更容易,而无需重写数据访问部分。

  4. 构建概念验证—到这一点,我们将拥有读取和过滤数据的功能,以及应用程序外观的计划。最后一步是使用我们在第一步中决定的工具构建概念验证。

前三个步骤是为构建概念验证的最后一步做准备。

11.2.1 准备构建概念验证

让我们解决第一步,即选择一个原型工具。如果您之前没有构建过可工作的概念验证或原型,这是探索您现有工具包中一些选项的好时机。

选择一个快速原型工具

例如,如果您是 R 用户,您可能会探索 Shiny 的功能。作为 Python 用户,您也有许多选项可供选择。这是利用大型语言模型来帮助您调查该领域的好机会。在这种情况下,我使用了 Anthropic 的 Claude 3 Sonnet 来展示不同的选项,而不仅仅是 ChatGPT。以下是我给它提供的提示:

我想用 Python 构建一个数据驱动的应用程序的交互式概念验证。请建议一些可以帮助我构建具有以下功能的基于 Web 的交互式概念验证的库:

    • 能够显示可视化,理想情况下使用生成 matplotlib 图表的现有代码。

    • 交互性,即下拉菜单来过滤数据,然后刷新页面及其所有可视化。

    • 应用程序需要基于 Web,这样用户就不需要安装任何软件来使其工作。

我将使用 pandas 库进行数据处理,并使用 matplotlib 创建图表,但理想情况下,所有其他功能都应该由一个额外的库来覆盖。请提供一个可能的选项列表,并解释每个选项的 1-2 句话。

Claude 列出了几个选项作为响应,如图 11.2 所示。

figure

图 11.2 Claude 3 Sonnet 对 Python 快速原型工具的建议

这些都是在 Python 生态系统中的可靠选项,并且所有这些都适合我们的概念验证。我选择streamlit主要是因为我已经熟悉它。否则,我会花一些时间阅读每个库的文档,并评估示例代码片段,以确定哪个库能让我最快地构建概念验证。

让我们开始跟踪这个项目部分的进度。图 11.3 显示了我们刚刚采取的第一步。

figure

图 11.3 构建概念验证的第一步

现在我们已经选择了工具,让我们考虑应用程序的布局。如果我们确切知道我们想要添加哪些元素以及它们的位置,那么我们可以以结果驱动的方式进行工作。我们只会阅读工具文档的必要部分,并且只使用我们实际将要使用的元素。

设计应用程序布局

在这部分,我们需要做两件事:决定页面上将放置哪些元素以及它们将放置在哪里。我们或多或少已经决定了这一点,所以让我们回顾一下:

  • 将会展示一个按县划分的价格分布脊图,用于 2023 年。

  • 将会有用户筛选功能,包括县、镇和街道。

  • 我们将展示随时间推移的交易折线图。

  • 将会展示两个条形图,分别显示按物业类型划分的频率和销售中位数。

  • 根据目标受众,我们可能还希望展示驱动各种图表和计算的基础数据。对于内部工具,我会始终考虑这样做,原因有两个:一是建立计算的准确性信任,二是允许利益相关者将底层数据导出到 Excel,他们不可避免地会想要这样做。对于面向客户的工具,这可能不是必要的,但鉴于这是一个将由内部用户评估的概念验证,我们将显示原始数据。

  • 我们还可能决定显示用户所选物业子集的一些摘要指标。

这些元素的确切位置主要是个人的选择,但我们应该至少从上到下逻辑地排列它们。首先,用户应该看到他们无法更改的组件,例如脊图。然后,任何后续元素将取决于用户输入,因此页面下方的下一个项目应该是县、镇和街道下拉菜单。之后,我们将显示摘要指标和图表,最后以底部的原始数据表结束。除了一些解释性文本外,这就是我们概念验证所需的所有内容。图 11.4 显示了我们要构建的基本线框原型。

figure

图 11.4 可能的应用程序布局原型

由于这个原型包括我们做出的某些具体决策,让我们将其添加到这个项目部分的图表中。最新版本如图 11.5 所示。

figure

图 11.5 项目第二部分的最新进度

现在我们有了我们的工具和布局的想法,让我们编写代码的数据访问部分。这是应用将用来读取我们的数据并根据用户输入获取其过滤版本的代码。

编写可重用的辅助函数

在软件开发中,通常将数据访问组件与表示层分离是一个好主意。即使是简单的概念验证,这也是一个好的实践,因为如果它们发展成为原型和实际应用,我们将有一个可重用的数据访问层,可以在所有阶段使用。

分析师所需的软件开发技能

如果你与代码打交道,你会从学习软件最佳实践中受益。分析师不需要成为软件开发人员,但编写良好软件的某些元素与数据专业人员相关。

良好的软件开发实践意味着可读性和可重用性,首先是可读性。一旦你养成了编写干净、可重用代码的习惯,这种代码在需求不可避免地发生变化时易于更改,你会发现生产力有显著提高。

如果你想了解更多关于数据人员的软件技能,一个极好的资源是 Laszlo Sragner 的《数据科学代码质量》,这是一个 Discord 社区,致力于教授所有数据科学家(无论技能水平如何)编写更好的代码,可在cq4ds.com找到。

在我们的特定应用中,我们将需要以下函数:

  • 将我们的属性数据作为 DataFrame 读取。

  • 获取县、镇和街道名称的列表,以填充用户将从中进行选择的下拉菜单。这些下拉菜单也应该相互依赖,这意味着例如,当用户选择一个县时,镇/城市下拉菜单应该更新以反映该县内的城镇。

  • 根据数据的过滤版本创建所有必要的可视化。这些函数将返回实际图表对象,供应用显示。

让我们逐一查看示例解决方案中的每个函数,这些函数可以在名为helpers.py的文件中找到。首先,这是一个简单的函数,用于读取我们之前准备和导出的数据:

import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns

from ridgeplot import ridgeplot

def load_price_data():
    return pd.read_parquet("./data/wales.parquet")

这就是应用首次启动时将运行的内容。然后,我们需要函数来填充每个下拉菜单。以下是一个返回数据中所有可能县份的函数:

def get_counties(df):
    return sorted(df["county"].unique())

注意这个函数并没有直接引用威尔士的属性;它只是返回任何 DataFrame 中所有的县值。这是因为应用的不同组件不应依赖于其他组件的具体实现。例如,返回县列表的功能不需要知道底层数据来自哪里。以下是根据给定县列出城镇的列表:

def get_towns(df, county, null_value):
    if (not county) or (county == null_value):
        return []

    return (
        [null_value]
        + sorted(
            df.loc[df["county"] == county, "town_city"]
            .unique()
        )
    )

这个函数接受一个空值作为附加参数,这是一个特殊值,用于当下拉菜单未选择时。如果一个用户已经选择了一个县和一个镇,但想回到查看整个县的数据,他们可以在镇的下拉菜单中选择这个空值来清除它。这个空值将显示为“未选择城镇”,因此用户可以清楚地知道他们已经清除了城镇下拉菜单。

最后一个下拉菜单将是一个街道名称列表,这取决于县和镇/市:

def get_streets(df, county, town, null_value):
    if (not (county and town)) or (town == null_value):
        return []

    return (
        [null_value] +
        sorted(
            df.loc[(df["county"] == county)
                   & (df["town_city"] == town),
            "street"]
            .unique()
        )
    )

现在我们需要一些函数来绘制我们的图表。首先,这是每年的交易数量,它将与图 10.25 中的图表类似:

def transactions_per_year(df):
    fig, axis = plt.subplots()

    (
        df
        .set_index("sale_date")
        .resample("YS")      #1
        .size()
        .plot(ax=axis, color="gray", marker="x")
    )

    axis.set(
        ylabel="# of transactions"
    )

    return fig

1 计算每年记录数,其中“YS”表示“年初”

接下来,我们想展示物业类型的细分。我们想展示物业类型的分布以及按物业类型的平均价格。我们可以通过将交易数量表示为条形图中的条形长度,将平均价格表示为颜色值来将此信息合并到单个图表中。然而,我们希望应用程序尽可能易于理解,因此我们将此信息分为两个条形图,以下两个函数如下:

def distribution_of_property_type(df):    
    fig, axis = plt.subplots()

    (
        df["property_type"]
        .value_counts()
        .sort_values()
        .plot
        .barh(ax=axis, color="gray")
    )

    axis.set(
        xlabel="# of transactions"
    )

    return fig

def median_price_by_property_type(df):
    fig, axis = plt.subplots()

    (
        df
        .groupby("property_type")
        ["sale_price"]
        .median()
        .sort_values()
        .plot
        .barh(ax=axis, color="gray")
    )
    axis.set(
        xlabel="Median price (£)",
        ylabel=None
    )

    return fig

最后,我们将包括上一章中创建的岭图,因此我们需要一个辅助函数来创建它。正如我们所发现的,岭图需要特定格式的数据,因此我们需要一个函数来创建这些数据,另一个函数来创建图表:

def get_county_ridgeplot_data(df, counties):
    sales_by_county = []

    for county in counties:
        prices = (
            df
            .loc[(df["county"] == county)
                 & (df["sale_price"] < 500_000)
                 & (df["year"] == 2023),
            "sale_price"]
        )
        sales_by_county.append([prices])

    return sales_by_county

def county_ridgeplot(sales_by_county, counties):
    fig = ridgeplot(sales_by_county,
                    labels=counties,
                    colorscale="gray",
                    coloralpha=0.9,
                    colormode="mean-minmax",
                    spacing=0.7)

    fig.update_layout(
        title="Distribution of house sale prices in Wales in 2023, by county",
        height=650,
        width=950,
        font_size=12,
        plot_bgcolor="rgb(245, 245, 245)",
        xaxis_gridcolor="white",
        yaxis_gridcolor="white",
        xaxis_gridwidth=2,
        yaxis_title="County",
        xaxis_title="Sale price (£)",
        showlegend=False
    )

    return fig

这些函数将被导入到应用程序中,应用程序可以使用它们来过滤我们的数据,填充下拉菜单,并根据用户输入显示正确的可视化。让我们将辅助函数的创建添加到我们的图中,以记录我们的进度。这如图 11.6 所示。

figure

图 11.6 构建概念验证的最新进展

现在,终于到了构建应用程序本身的时候了,使用我们选择的工具以及我们之前创建的应用程序布局和辅助函数。

11.2.2 使用 streamlit 构建概念验证

我们有我们选择的好工具 streamlit,一个期望的布局,以及管理数据访问、过滤和图表的辅助函数,这样我们就可以非常针对性地构建我们的概念验证。我们需要弄清楚 streamlit 的基础知识以及如何显示文本、下拉菜单、图表和表格。这意味着我们不会迷失在教程和代码示例的海洋中——我们只会取我们需要的。

现在,我们将逐一介绍最终应用程序的各个组件,其代码可以在名为 house_price_app.py 的文件中找到。要查看示例解决方案的最终应用程序的实际运行情况,请打开终端或命令提示符,使用 poetry shell 命令激活 poetry 环境,然后运行 streamlit run house_price_app.py 命令,该命令应类似于图 11.7 中的示例。有关设置 Python 环境以重现示例解决方案的更多信息,请参阅附录。

图片

图 11.7 展示了如何在示例解决方案中运行应用程序的命令提示符窗口

要构建应用程序,我们首先设置一些 streamlit 选项并读取我们的数据。

将数据读入 streamlit

首先,我们将导入必要的库并设置一些 streamlit 选项,即我们希望页面为全宽,这不是默认选项:

import streamlit as st
import pandas as pd
import helpers      #1

st.set_page_config(layout="wide")

1 在此上下文中,helper 是我们之前在 helpers.py 中编写的代码。

接下来,我们读取属性数据以及将填充县下拉菜单的数据,因为这些数据不会动态变化。我们还可以读取将提供脊图的数据,因为同样,这些数据也不会变化。可选地,我们还可以让 streamlit 缓存这些数据集,这意味着它们在用户执行某些操作(如更改下拉菜单)时不会重新加载。我们通过将辅助函数包装在小函数中并用 st.cache_data 装饰器注释它们来实现这一点。以下代码负责加载数据和缓存数据:

@st.cache_data
def get_price_data():
    return helpers.load_price_data()

wales = get_price_data()

@st.cache_data
def get_counties(wales):
    return helpers.get_counties()

counties = get_counties(wales)

@st.cache_data
def get_county_data(wales, counties):
    return helpers.get_county_ridgeplot_data(wales, counties)

sales_by_county = get_county_data(wales, counties)

接下来,我们定义应用程序的布局。

streamlit 中定义应用程序布局

应用程序将简单地是按照从上到下的顺序定义的 streamlit 元素——首先,标题,简要说明,以及脊图。以下代码构建了这些元素,并且最终应用程序的相关部分如图 11.8 所示。由于篇幅原因,完整的解释省略了代码片段,但可以在图中看到:

st.title("House price explorer - Wales")
st.markdown("""This tool lets you explore...""")

st.plotly_chart(helpers.county_ridgeplot(sales_by_county, counties))

图片

图 11.8 房价应用程序概念证明的第一元素

接下来,我们将添加交互性,包括三个下拉菜单,它们可以深入特定地理区域。默认情况下,将元素添加到 streamlit 将将其添加到上一个元素下方。由于我们希望下拉菜单在同一行,我们可以创建一些列并将下拉菜单添加到它们中,以避免这种情况。每个下拉菜单都将是一个 streamlit selectbox,它将使用我们的辅助函数进行填充。依赖于其他下拉菜单的下拉菜单将引用其他下拉菜单的值。也就是说,选择城镇列表将取决于县下拉菜单中选择的值,该值将传递给 get_towns 辅助函数。以下代码以三列格式创建了三个下拉菜单,并且应用程序的相关部分如图 11.9 所示:

st.header("Explore house prices at different levels")

select_col1, select_col2, select_col3 = st.columns(3)

with select_col1:
    county_select = st.selectbox(
        'Select a county:',
        helpers.get_counties(wales),
        index=None,
        placeholder="-- Select a county --"
    )

TOWN_NULL_VALUE = "-- No town selected --"    #1

with select_col2:
    town_select = st.selectbox(
        'Select a town:',
        helpers.get_towns(
            wales,
            county_select,     #2
            null_value=TOWN_NULL_VALUE),
        index=None,
        placeholder=TOWN_NULL_VALUE
    )

STREET_NULL_VALUE = "-- No street selected --"

with select_col3:
    street_select = st.selectbox(
        'Select a street:',
        helpers.get_streets(
            wales,
            county_select,
            town_select,
            null_value=STREET_NULL_VALUE),
        index=None,
        placeholder=STREET_NULL_VALUE
    )

1 如果用户想要清除下拉菜单,可以选择的值示例

2 当前选中的县将被传递给 get_towns 函数。

figure

图 11.9 地域下拉菜单的初始状态

图 11.9 显示了下拉菜单的初始状态。城镇和街道是浅灰色,这意味着它们目前处于禁用状态,直到用户选择一个县。图 11.10 显示了当一些值被选中时下拉菜单的状态,以及街道的选项。

figure

图 11.10 地域下拉菜单中选中了一些值,并显示了街道的可用值

接下来,我们需要一些代码,它将根据用户的选择将原始数据过滤到正确的地域级别。

在 streamlit 中交互式过滤数据

根据你的工具,这看起来可能会有所不同,但在这里,我们将利用pandas中的query方法,它允许我们以单个字符串的形式指定应用于我们数据的过滤器。我们可以根据有多少下拉菜单有选中的值来构建这个查询字符串。用户使用的下拉菜单越多,查询字符串越长,查询越具体,我们检索的数据就越少。以下代码构建了这个查询字符串并将其应用于数据。我们还构建了一个将反映用户选择的短信:

house_filter_query = "county == @county_select"
filter_message = f"Results for {county_select}"

if town_select and town_select != TOWN_NULL_VALUE:
    house_filter_query += " and town_city == @town_select"
    filter_message += f", {town_select}"

if street_select and street_select != STREET_NULL_VALUE:
    house_filter_query += " and street == @street_select"
    filter_message += f", {street_select}"

selected_data = wales.query(house_filter_query)

接下来,基于这些过滤后的数据,我们可以开始计算汇总指标,并创建如图 11.4 所示的线框图中的图表。为了计算汇总指标,我们需要计算出过滤数据中的中位数售价,以及记录的数量。以下代码完成了这项工作,并且只有当用户至少选择了一个县时才会执行。之前构建的消息示例以及汇总指标如图 11.11 所示:

median_price = selected_data["sale_price"].median()

if county_select:   #1
    st.header(filter_message)

    metric_col1, metric_col2 = st.columns(2)
    metric_col1.metric("Number of records", f"{len(selected_data):,.0f}")
    metric_col2.metric("Median sale price", f"£{median_price:,.0f}")

1 选择一个县就足以触发小部件。

figure

图 11.11 显示用户应用的地域过滤器和相关汇总指标的消息

现在,我们可以使用我们的辅助方法来创建相关的图表。

在 streamlit 中创建图表

图表将并排显示,因此它们需要再次放在streamlit列中。最后,我们在页面底部显示过滤后的数据作为表格。以下代码完成了我们的应用。因为整个底部部分依赖于用户至少选择了一个县,所以我包括了包含之前看到的汇总指标的整个代码块:

if county_select:
    st.header(filter_message)

    metric_col1, metric_col2 = st.columns(2)
    metric_col1.metric("Number of records", f"{len(selected_data):,.0f}")
    metric_col2.metric("Median sale price", f"£{median_price:,.0f}")

    chart_col1, chart_col2, chart_col3 = st.columns(3)

    with chart_col1:
        st.subheader("Transactions over time")
        st.pyplot(helpers.transactions_per_year(selected_data))

    with chart_col2:
        st.subheader("Distribution of property type")
        st.pyplot(helpers.distribution_of_property_type(selected_data))

    with chart_col3:
        st.subheader("Median sale price by property type")
        st.pyplot(helpers.median_price_by_property_type(selected_data))

    st.header("Raw data")
    st.write(selected_data)

最后,让我们看看应用的最后一部分,它包含汇总指标、图表和基础数据。原始数据在图中被截断,但在应用中完整显示。这如图 11.12 所示。

figure

图 11.12 完成概念验证应用的底部部分,其中原始数据表被截断

在我们传达我们的结论之前,让我们回顾一下在这个项目中我们所做的一切。图 11.13 显示了探索和清理数据的过程,以及概念验证应用程序的设计和开发。

figure

图 11.13 从探索和分析到构建概念验证的整个过程

我们已经成功交付了我们的最小可行答案。现在是时候反思我们所发现的内容以及下一步该做什么了。

11.2.3 项目成果和下一步计划

我们通过调查物业类型来满足简报要求,我们发现不同物业类型的售价之间存在差异,并通过调查街道级数据。我们可以自信地向我们的利益相关者保证,这些数据适合查看这两个方面。然后,我们通过构建一个概念验证来展示他们计划中的应用程序可能的样子,以及是否可以使用现有数据进行构建,从而超越了简报的要求。

关于我们的概念验证,有几个要点需要解决:

  • 首先,相关地址字段中存在缺失值。为了启动并可能为此类应用程序收费,数据需要完整。为此,我们需要清理数据并填写缺失的地址细节,可能使用第三方地理编码服务,如谷歌地图 API。

  • 其次,即使只有少量可用记录,应用程序也会显示与用户选择相关的图表,如图 11.12 所示。根据我们认为用户将如何使用总结性指标和图表,如果返回的属性样本量太小,我们可能希望在应用程序中发布免责声明。

  • 最后,如果我们能解决一些这些问题,并且概念验证达到了原型阶段,我们就需要用户测试以确保用户能够找到相关的地址。在这种情况下,这意味着确保县、镇和街道的值被正确分类。

当向我们的利益相关者展示这个应用程序时,我们可能还希望提出对最终版本的改进建议。以下是一些想法:

  • 用户可能会在看到物业列表网站上的物业后进行调查,例如 Rightmove 或 Zoopla。在这些网站和我们的应用程序之间建立链接可能很有用。例如,用户可能将 Rightmove 上的物业链接粘贴到我们的应用程序中,然后应用程序可以根据该物业的地址自动过滤数据。

  • 自动过滤结果的另一种选项可能是简单地输入地址或在交互式地图上选择它。这将节省用户时间并使他们的体验更加流畅。

  • 我们可以通过添加可能影响物业价格的地方信息来增强我们的地址数据,例如犯罪率或公共设施的可用性。

无论我们接下来选择做什么,它都将从一个向我们的利益相关者展示的演示开始,并讨论是否将此项目进一步推进到原型阶段。

活动:使用这些数据进一步的项目想法

想想你可以用这个房价数据集进行哪些其他分析。特别是,你可能想练习不同的构建概念验证的方法,以发展你在这一领域的技能。以下是一些你可能希望探索的方向:

  • 这样的地理数据非常适合基于地图的视觉展示。你可以创建一个应用程序来识别交易热点,即国家中“周转”率很高的地区。

  • 房产销售中是否存在任何季节性模式?这些模式在不同地理区域之间是否有所不同?

  • 通过增强数据中的人口统计信息(例如,人口数据),你可以查看在全国范围内是否有比该地区典型规模更多的房产销售。

11.3 关于快速原型化想法的结语

正如我们在本项目中看到的,通过从可用数据构建概念验证来扩展分析是有用的。对于构建预测模型来说也是如此;而不是展示准确度指标,如果我们可以展示它们在实际中的工作方式,这些模型就会变得生动起来。拥有概念验证和原型还可以让我们识别出会影响构建完整工作产品的数据问题。

要特别提高在这个领域的技能,有几种方法:

  • 显而易见的一个方法是熟悉快速原型化工具。这可能包括使用 BI 工具(如 Power BI 或 Tableau)构建仪表板,或者学习你编程语言的库,例如 R 的 Shiny 或 Python 的streamlit。或者,你可以学习关于“无代码”平台的知识,在那里你可以不明确编写代码就制作出工作应用程序。

  • 另一项可能有助于此的技能,尤其是如果你想要构建工作原型的话,就是学习关于网络构建的知识。具体来说,学习一点 HTML、CSS 和 JavaScript 对于制作定制的基于网络的程序很有帮助。例如,Web 设计游乐场(www.manning.com/books/web-design-playground-second-edition)是一个学习基础知识的绝佳平台。

  • 如果你将在工作中构建概念验证和原型,学习设计原则也是有用的。了解 UI 和 UX 设计原则、用户流程和故事板的基础知识将帮助你构建更好的原型。

当然,学习这项技能的最佳方式是通过实践。寻找机会构建小型交互式应用程序来补充你的分析。这将对你和展示给利益相关者的你都是有益的学习经历。

11.3.1 任何项目快速原型化的技能

要基于可用数据构建交互式概念验证,关键技能包括,这些技能可以应用于任何类似项目:

  • 在探索可用数据时,关注原型的功能

  • 验证我们数据的质量,因为它将暴露给外部客户

  • 从信誉良好的来源增强数据(例如,使用官方城市名称)

  • 探索我们数据中的有趣变化,以确定在概念验证中应关注什么

  • 确定适合原型和目标受众的视觉化方法

  • 选择合适的快速原型工具来构建概念验证

  • 在编写任何代码之前,将应用布局设计为线框图

  • 编写应用程序可以使用但与应用程序代码紧密耦合的帮助函数

  • 设置快速原型工具,如streamlit

  • 在原型工具中实现所需的布局

  • 在您的原型工具中显示数据和图表

  • 为用户与展示的数据和可视化提供交互

摘要

  • 从想法到工作产品的旅程应包括概念验证和原型的创建,这两个领域都是分析师可以参与的地方,并且是分析师工具箱的有用补充。

  • 概念验证和原型是使分析对利益相关者生动起来的有效方式。

  • 选择用于创建概念验证的工具应取决于现有工具,目标受众如何与最终产品互动,以及使用所选工具创建概念验证的速度。

  • 快速原型设计也需要以结果为导向的方法,以确保仅在初始版本中构建必要的功能。

第十二章:12 在他人的工作中迭代:数据准备

本章涵盖

  • 继续另一位分析师的工作

  • 调查和验证现有分析

  • 准备事件级别数据以适合用户级别分割

每个分析师在某个时候都需要继续他人的工作。这个人可能是几个月前的你。处理项目第二版的过程与从头开始是一样的。

因为我们将拥有这个新版本,即使别人已经做过,我们仍然需要理解问题、查看可用数据等等。在这个项目中,你将有机会练习接替他人。另一位分析师已经为利益相关者问题准备了一个最小可行答案,你将在此基础上进行迭代。

本章的具体主题是现实世界中也很常见的一个问题:分割。大多数企业都会提出类似“某些事物与其它事物有哪些相似之处?”的问题,而这个问题中的“事物”可以是任何东西,从产品到客户,甚至是一个完整的地理区域。

真实业务案例:基于购买活动分割客户

用于分割客户的具体方法对于分割任何事物都很有用。例如,我参与的一个项目是识别在拍卖会上购买类似二手车的客户。有了买家相似性的概念,我们就能找到更多买家,并主动向他们推荐即将到来的拍卖。因为我们知道他们会购买类似的股票,所以能够邀请买家参加拍卖,这导致了更有效的对话,并使更多人关注我们拍卖的车辆。

找到相似实体的过程,我们在这里称之为“分割”,无论实体的类型如何,都是相同的。具体来说,在本章中,你将练习根据用户的 APP 使用活动将移动用户分组。你将使用事件级别数据,并继续另一位分析师已完成的项目迭代的工作。

12.1 寻找相似实体

分割问题出现在许多地方。典型的用例是根据人口统计或行为等特征找到彼此相似的客户。企业还可能希望将其产品目录应用于分割,以分类和简化其产品提供。

在每种情况下,结果都是对不同分割采取不同的行动。不同财务段的用户将被针对不同的银行产品。可能被分组到同一分割的产品可能被推荐给最终用户。推荐引擎大量使用用户和产品相似性的概念。

在机器学习中,细分或聚类是无监督学习的一个例子。这意味着,与监督预测问题不同,我们没有“真理”的例子,即实际客户细分来比较我们的结果。因此,我们衡量我们的细分好坏的能力是有限的。这些细分通常由人类专家主观评估。

监督学习与无监督学习

在机器学习中,监督学习与无监督学习之间的区别在于我们是否有历史正确的预测例子来比较我们的模型预测。

如果我们根据房屋的特性预测房价,我们需要包含房屋特性和其售价的训练数据。这是监督学习,因为我们可以将我们的模型预测与实际值进行比较,从而监督我们的预测模型。

无监督学习中,我们没有这样的过去例子。没有正确答案可以与之比较。相反,我们试图在

我们的数据,然后我们对其进行更主观的评价。客户细分恰好属于这个后者类别。

在任何相似性问题中,当我们决定我们的方法时,需要考虑某些因素:

  • 当我们有了我们的组时,我们将采取什么行动?这是最重要的问题,因为它激励了整个项目。

  • 在确定相似性时,哪些特征是重要的?我们是否关心住在同一地区或具有相似购买模式的人?

  • 在相关的问题上,我们是否有冗余的特征?如果我们正在细分汽车,我们不想在千米和每小时英里数中包含它们的最高速度。一个变量来衡量一个特定的概念就足够了。

  • 我们相信我们会找到多少组?我们可能没有确切的数字,但即使是一个大致的范围也会有所帮助。

  • 我们将如何评估细分的结果?通常,这是在领域专家的帮助下完成的。

在这个项目中,除了这些因素外,我们还想有一个清单,列出如何继续前一位分析师的工作。

12.2 继续他人的工作

在我们继续他人工作的情境中,第一步应该是复制他们的发现,无论原始源代码是否可用。这样做的原因是

  • 拥有我们自己的先前流程版本意味着我们可以更容易地对其进行更改。

  • 重新创建他人的工作是为了检查错误,而不是为了责怪任何人,而是为了验证他们的步骤是否如他们所说的那样做了。

  • 我们还想验证前一位分析师工作中存在的假设。他们可能做出了错误的假设,甚至可能做出了与我们不同的假设。明确这些假设可以让每个人都很清楚。

  • 复制工作使我们能够更好地理解项目的细节、局限性以及可能的进一步步骤。简要查看他人的工作并不等同于亲自深入其中。

如果我们在开始工作之前考虑这些因素,并结合我们的以结果为导向的方法,我们将为项目的成功做好充分准备。

与他人协作

在这个项目中,您通过继续另一位分析师的工作间接地与另一位分析师协作。您不总是项目中的唯一分析师;有时您需要与其他分析师共享工作。成为一名有效的协作者意味着

  • 与其他技术同事沟通您的工作

  • 将最佳实践应用于您的工作中,使您的代码更容易使用

  • 不断寻找改进和学习的方法

为了磨练这些技能,您可以参与开源项目或花时间研究他人的工作。

现在让我们看看这一章的项目。

12.3 项目 8:从移动活动中发现客户细分

在这个项目中,我们将研究手机活动以识别相似客户群体。我们将审查问题陈述、可用数据、项目交付成果以及您尝试项目所需的工具。

数据已可供您在davidasboth.com/book-code自行尝试项目。您将找到可用于项目的文件,包括我们将在此基础上构建的前分析师的工作,以及以 Jupyter 笔记本形式的示例解决方案。

12.3.1 问题陈述

您在 AppEcho Insights 工作,这是一家专注于移动用户行为的分析公司。他们分析用户如何使用手机,并向手机制造商和应用程序开发者提供见解。他们的最新举措是客户细分。他们想了解是否存在行为相似的用户群体。了解这些用户细分对他们的客户来说将非常有用,因为他们的客户可以用不同的举措针对整个用户群体。例如,他们可以向休闲用户推广生产力技巧,向重度用户提供延长电池寿命的见解。

前一位分析师已经对用户群体进行了基本的细分,并向利益相关者展示了他们的发现。不幸的是,他们之后离开了公司,并且他们的代码丢失了。您的经理要求您使用第一版分析的结果作为起点,进行第二次分析。

第一版本的结论是,可以根据用户使用的应用数量和浏览会话的平均长度对客户进行细分。分析师提出了六个独特的客户类别,并为他们分配了角色,例如“休闲用户”和“高级用户”。你的利益相关者希望第二版本的细分更加复杂,并包含更多特征。他们希望关注以下方面:

  • 人们使用的应用类型。例如,一些客户是否比其他人更频繁地使用社交媒体应用?

  • 观察时间模式。用户是否喜欢在一天中的不同时间浏览他们的手机?

  • 可能需要使用更合适的分段方法(例如,可以基于多个维度对用户进行分组的算法)。

他们已经为你提供了移动事件数据集和初始分析师提供的演示文稿。数据集包括单个移动事件,例如用户打开或关闭移动应用。列数不多,但可以提取丰富的行为信息。事件都有时间戳,因此时间元素也可以进行分析。有关处理时间数据的更多详细信息,请参阅第八章和第九章。

备注  数据最初来自github.com/aliannejadi/LSApp。感谢 Mohammad Aliannejadi 允许使用数据。

数据和演示文稿文件都在补充材料中。你的任务是审查分析师的工作,并继续项目以回答利益相关者的问题。

12.3.2 数据字典

总是,一个关键的初始步骤是查看可用的数据。表 12.1 显示了数据字典。

表 12.1 数据字典
定义
用户 ID 一个用户的唯一标识符。
会话 ID 唯一标识用户的会话活动。
时间戳 单个事件的日期和时间。
应用名称 正在使用中的应用名称。
事件类型 发生的事件类型。值可以是以下之一:“打开”、“关闭”、“用户交互”或“损坏”。

现在我们已经看到了可用的数据,让我们看看这个项目的成果。

12.3.3 预期成果

我们的利益相关者希望进行更深入的分析,并基于更多维度创建客户细分。我们的解决方案应包括对初始分析的重新创建以及我们的改进。最终输出是我们用户细分的定义,即哪些因素描述了每个细分,哪些用户属于哪个组。

12.3.4 必需工具

在示例解决方案中,我使用 Python 和pandas库进行数据探索,matplotlib进行可视化,以及scikit-learn进行聚类。为了完成这个项目,你需要一个能够

  • 从 CSV 或类似文件加载数据集

  • 执行基本数据操作任务,如排序、分组和重塑数据

  • 创建数据可视化

  • 执行分割(例如,使用聚类算法)

您可以选择手动执行分割,这意味着决定哪些维度的值组成组。然而,在两个或三个维度之后,手动执行变得越来越困难,这就是为什么我建议选择一个可以应用相关算法的工具。

现在让我们看看我们如何可能使用我们的以结果为导向的框架逐步解决这个问题。

12.4 将以结果为导向的方法应用于创建客户细分第二迭代

让我们看看以结果为导向的解决方案,并制定我们的行动计划。

figure

问题陈述是清晰的:将用户分配到不同的细分市场。除此之外,我们还需要了解前一位分析师已经完成的工作。只有这样,我们才能确切地了解我们的任务是什么。重现分析师的工作也是至关重要的。首先,我们需要确保我们可以复制他们的结果并验证他们的假设,但在这个案例中,我们还需要有代码可用,因为原始代码已经丢失。

figure

从末尾开始,在这种情况下,意味着回答第 12.1 节中提出的问题。应该有多少个组?根据我们的利益相关者想要如何使用输出结果,哪种类型的组是有意义的?他们希望能够针对不同的用户群体提供不同的产品,因此我们在评估用户群体时应该考虑到这一点。

figure

在这个实例中,数据已经被为我们识别出来。然而,我们也应该考虑如何增强这些数据。我们能否从数据中提取更多信息,或者是否有外部数据源我们可以将其合并?

figure

数据已经以原始形式为我们下载。然而,我们仍然可以增强它。在示例解决方案中,我们将回顾一些增强的想法。

figure

分割过程与大多数数据分析问题相似:

  • 我们将首先探索数据,寻找缺失和错误的数据值、异常值等。

  • 接下来,我们将调查我们的记录在哪些特征上有所变化。也就是说,某些特征的变化是否比其他特征更大?如果所有客户都居住在同一个地理区域内,那么就没有必要将地理作为分割客户的一个维度。

  • 我们还希望沿着不同的维度可视化我们的数据,以期发现明显的组。在大型、复杂的数据集中,这不太可能发生,但我们可能会发现异常值组,例如一些客户的花费远高于其他人。

  • 最后两个步骤是分割问题的独特之处。首先,我们将选择要分割的维度并应用聚类算法。存在不同的算法适用于不同的用例,其中一些在示例解决方案中进行了讨论。

  • 然后,我们将评估结果。我们将通过分析这些组来查看它们是否在意义上彼此不同。一种方法是为每个组分配组标签或角色。如果我们能给出每个组的独特描述,结果将比我们创建了具有相同特征的多组更有用。

此步骤将在我们为每个用户分配资源后完成,也就是说,我们知道他们属于我们新创建的哪些组。

figure

上一次迭代的演示文稿是一个简短的幻灯片集。我们可以考虑创建一个类似的演示文稿作为输出。至少,我们应该有所有相同的成分在手:最终组的列表、每个组中的用户数量以及描述每个组的特征。

figure

由于这将是我们项目的第二次迭代,我们应该向我们的利益相关者提出建议,关于我们版本完成后可能采取的额外步骤。显然,这是一个重要的公司倡议,如果我们利益相关者决定分配更多资源,我们可以通过提供进一步工作的建议来支持它。

12.5 示例解决方案:创建客户细分

现在,让我们通过一个示例解决方案来了解整个过程。一如既往,我强烈建议您首先尝试自己完成这个项目。

至于行动计划,首先,我们将尝试重新创建初始分析。接下来,我们将调查我们的利益相关者请求的附加功能,最后将我们的客户分组到新的细分市场并分析这些组。

12.5.1 重新创建他人的分析

在本部分的第一部分,我们将回顾前一位分析师展示的幻灯片,并尝试复制他们的发现。幻灯片 4 包含一些汇总指标,这是我们应从哪里开始的地方。图 12.1 显示了相关的幻灯片。

figure

图 12.1 原始演示文稿中的幻灯片 4,显示了汇总指标

我们的首要任务是验证我们能否重新创建这些指标。

验证报告的指标

首先,我们将使用以下代码读取数据,并在图 12.2 中展示行预览。请注意,数据格式为.tsv,意味着值由制表符分隔,而不是逗号:

import pandas as pd
import matplotlib.pyplot as plt

app_data = pd.read_csv(
    "./data/lsapp.tsv.gz",
    sep="\t",      #1
    names=["user_id", "session_id", "timestamp",
           "app_name", "event_type"],     #2
    parse_dates=["timestamp"],      #3
    skiprows=1     #4
)
print(app_data.shape)
app_data.head()

1 指定分隔符为制表符

2 给数据指定特定的列名

3 明确将时间戳列转换为日期类型

4 忽略原始列标题,因为我们提供了自己的

figure

图 12.2 原始数据的快照

代码的输出告诉我们有超过三百万行数据,快照告诉我们每行对应一个应用事件,即用户打开或关闭应用时。现在,让我们调查附录幻灯片上的声明,该声明称丢失的数据和标记为损坏的事件已被删除。相关的幻灯片如图 12.3 所示。

figure

图 12.3 幻灯片附录中相关的部分,显示了额外的数据清理步骤

为了调查这些额外的数据清理步骤,以下代码调查缺失数据,并在图 12.4 中产生输出:

app_data.isnull().sum()

图

图 12.4 查询缺失数据的结果

此图显示每个列中都有一个缺失值。无论这些缺失值是否在同一记录中,都可以安全地删除缺失数据,因为它只占我们数据集的一小部分:

app_data = app_data.dropna()

图 12.2 也显示,用户 ID 和会话 ID,通常应该是整数,被当作十进制值处理。这是因为我所使用的pandas库版本没有可空整数类型。将此列转换为整数不是至关重要,但我们仍会这样做以明确表示并避免任何混淆。图 12.5 验证了所有列都有正确的数据类型:

app_data["user_id"] = app_data["user_id"].astype(int)
app_data["session_id"] = app_data["session_id"].astype(int)

app_data.dtypes

图

图 12.5 我们数据集中的修正数据类型

在验证幻灯片上的摘要指标之前,让我们调查根据附录,被删除的“损坏”事件类型。以下代码查看事件类型的分布,作为整个数据的百分比,结果如图 12.6 所示:

app_data["event_type"].value_counts(normalize=True)

图

图 12.6 每个事件类型的百分比分解

此图告诉我们,我们数据中只有 0.1%是“损坏”事件。我们没有太多关于这代表什么的信息,所以我们同意初始分析,并使用以下代码删除这些记录。

app_data = app_data[app_data["event_type"] != "Broken"]

在我们继续之前,让我们开始构建我们正在遵循的过程图。图 12.7 显示了我们所做的一切以及分析可能发生分歧的地方。

图

图 12.7 重新创建初始分析的第一步

现在,让我们验证根据图 12.1 中“数据集概览”幻灯片,我们有 292 个用户,87 个独特应用程序,跨越八个月的事件,以及三种不同的事件类型。首先,我们验证用户数量:

app_data["user_id"].nunique()

此代码返回预期的 292。现在,我们验证不同应用程序的数量:

app_data["app_name"].nunique()

再次,我们得到了预期的 87。现在,让我们看看我们数据中的日期范围:

app_data["timestamp"].agg(["min", "max"])

输出告诉我们数据跨度为 2017 年 9 月至 2018 年 5 月,大约八个月,正如幻灯片上所述。我们还知道有三个事件类型,因为我们最初调查时是四个,我们完全删除了一个。下一步是重新创建幻灯片中图表背后的部分基础数据。

首先,有一张幻灯片显示了每个用户的会话数量,如图 12.8 所示。

图

图 12.8 原始演示文稿中的一张幻灯片,显示了每个用户的会话数量分布

每个用户的会话数量似乎是一个有用的指标来计算,因为它是一个用户活跃度的代理。为此,我们将创建一个 DataFrame,其中对于每个用户,我们计算他们在数据中拥有的唯一会话数量。然后我们将探索这个指标的分布。以下代码创建了此 DataFrame,其描述性统计结果如图 12.9 所示:

sessions_per_user = (
    app_data
    .groupby("user_id")
    ["session_id"]
    .nunique()    
)

sessions_per_user.describe()

figure

图 12.9 每个用户的会话数量描述性统计

这告诉我们,每个用户的会话中位数是 95,但有些用户只有一次会话,而有些用户超过 5,000 次。让我们通过可视化分布来更好地理解数据的分布和集中情况。以下代码创建了图 12.10 所示的直方图:

fig, axis = plt.subplots()

sessions_per_user.hist(bins=20, ax=axis)

axis.set(
    title="Sessions per user",
    xlabel="Number of sessions",
    ylabel="Frequency"
)

plt.show()

figure

图 12.10 每个用户的会话数量分布

如我们从描述性统计中可能预期的,分布告诉我们大多数用户会话很少,但有一个长尾延伸到拥有数千次会话的用户。初步分析没有提到这些多产用户的情况,所以让我们调查他们。在我们这样做之前,让我们更新我们的图表来记录到目前为止的过程。图 12.11 显示了最新版本。

figure

图 12.11 我们迄今为止采取的步骤

从这个点开始,我们将超越幻灯片上展示的结果,进入我们自己的分析。现在让我们看看具有最高会话数量的用户的数据。以下代码找到了具有最多会话的用户,输出结果如图 12.12 所示:

sessions_per_user.sort_values(ascending=False).head(5)

figure

图 12.12 最具独特会话的用户

以下代码提取了具有最多会话的用户(用户 138)的原始数据。他们的数据快照如图 12.13 所示:

app_data[app_data["user_id"] == 138].head(10)

figure

图 12.13 具有最多会话的用户活动快照

看起来这个用户的数据从大约早上 5:30 开始使用各种应用。这个数据的一个明显方面是它从一个“打开”事件开始,紧接着是一个“关闭”事件,然后是另一个“打开”事件,所有这些事件都是针对同一应用的。这个信息是相关的还是冗余的?在图 12.13 的例子中,我们有一些精确重复,即第一行和第三行。让我们看看这个问题有多大。

调查重复事件记录

以下代码计算了精确重复记录的比例:

app_data.duplicated().sum() / len(app_data)

输出结果是 0.597,这意味着我们近 60%的数据是另一行的精确重复。这意味着具有相同用户 ID、相同会话 ID 和相同事件类型在相同时间发生同一应用的记录。

这个结果可能需要大量工作来调查和澄清,所以在我们做任何事情之前,让我们考虑一下重复项是否是我们想要进行的特定分析的问题。我们感兴趣的是

  • 人们使用哪些类型的应用?如果我们计算每个用户独特的应用数量,重复记录就不会成为问题。

  • 人们何时使用他们的手机?不幸的是,由于可能在不同时间有更多重复记录,重复记录会导致结果偏差,我们可能会无意中夸大一天中某些部分相对于其他部分。

因此,我们不能忽视存在重复记录的事实。我们需要权衡我们的可能选项:

  • 我们可以简单地删除重复记录。如果我们对图 12.13 中的示例这样做会发生什么?我们会得到一个“打开”事件后面跟着两个“关闭”事件。由于“关闭”事件发生在不同时间,它们不会是重复的。删除完全重复的记录过于基础,并可能引起其他问题。

  • 另一个选择是删除同时发生的“打开”和“关闭”事件对。然而,这假设“打开”事件总是有一个相应的“关闭”事件,这在实践中可能并不成立。

  • 我们也可以从不同的角度思考这个问题。目前,我们并不对单个事件级别感兴趣,而是要总结用户级别的行为。这意味着我们可以认为“关闭”事件信息量较小。如果一个用户在浏览会话中打开了五个应用,即使没有“关闭”事件,我们也会知道这一点。

“关闭”事件信息上冗余的想法非常吸引人,所以我们采用这种方法。以下代码删除了“关闭”事件,并计算了我们删除的记录百分比以及剩余数据中完全重复记录的百分比:

closed_dropped = app_data.loc[app_data["event_type"] != "Closed", :]

print(len(closed_dropped))
print(len(closed_dropped) / len(app_data))
print(closed_dropped.duplicated().sum() / len(closed_dropped))

这三个命令的输出分别是 1,987,090,0.54 和 0.599。这意味着我们剩余的记录数接近两百万,而最初有 370 万条记录,大约是原始数据的一半。这也告诉我们,我们仍有近 60%的完全重复记录。现在,对于我们的目的来说,完全重复是完全没有必要的,因此我们也可以删除这些记录。以下代码执行此操作,并计算我们剩余的数据量:

app_data_reduced = closed_dropped.drop_duplicates()
len(app_data_reduced)

我们最终剩下不到 80 万条记录。在分析过程中删除如此多的数据是不寻常的,但在这个案例中,这是有道理的。图 12.14 显示了至今为止的过程,包括我们刚刚做出的选择。

figure

图 12.14 至今为止的过程,包括处理重复数据的决策

经过清理了大量重复数据后,让我们重新审视我们的数据。以下代码检查了前几行,如图 12.15 所示:

app_data_reduced.head(10)

figure

图 12.15 移除重复记录后的示例事件

这个最新的图显示了一个新的挑战。有用户连续打开扫雷游戏的实例,有时间隔只有几秒钟。这可能意味着什么?

  • 用户是否一直在改变他们是否想要玩游戏的想法?

  • 用户是否尝试打开应用,但它不断崩溃?

  • 这些是数据收集/整理过程中的问题吗?

我们需要更多信息才能确定。

处理相关的事件记录对

按现状,我们必须就如何处理这类重复问题做出选择:

  • 我们可以假设与一个应用相关的多个重复事件实际上是用户使用该应用的单一实例。在数据中,某人打开扫雷 10 次之后打开另一个应用的情况下,我们可以将其视为 2 个事件而不是 11 个。

  • 我们可以查看同一应用交互之间的时间差,并移除过短的交互。这需要我们严格定义“过短”,这是一个强烈的内置假设。

  • 我们也可以完全忽略这个问题。再次强调,我们感兴趣的是用户级和会话级指标,而不是单个事件级指标。如果我们通过会话开始和结束的时间差来定义用户会话的长度,那么用户以某种方式与手机交互期间发生的事情并不重要。

以结果为导向思考这个问题,我们可以回答利益相关者关于应用类型和用户浏览时间的问题,而无需明确处理用户快速连续打开和关闭应用的情况。由于我们做出了另一个选择,我们将它添加到我们的图中,最新的版本如图 12.16 所示。

图

图 12.16 调查各种重复事件后的最新进展

现在我们已经处理了重复问题,可以重新创建与每个用户的平均会话长度相关的图表,如图 12.17 所示。

图

图 12.17 原始演示文稿中显示用户平均会话长度分布的幻灯片

为了重新创建这个图表,我们需要创建一个包含单个会话及其长度的 DataFrame。首先,我们需要确定会话 ID 是否对用户是唯一的。如果会话 1 只能与用户 0 相关联,我们就可以简单地按会话 ID 分组;否则,我们必须包含用户 ID 以区分两个不同用户的会话,这两个会话的 ID 都是 1。以下代码查找属于多个用户的会话 ID:

(
    app_data_reduced
    .groupby("session_id")
    ["user_id"]
    .nunique()
    .loc[lambda x: x > 1]
)

此代码的输出是一个空的pandas Series,这意味着没有会话 ID 跨越多个用户。因此,会话 ID 对用户是唯一的。现在,我们可以创建所需的会话 DataFrame。以下代码执行此操作,并生成 DataFrame,如图 12.18 所示预览:

sessions = (
    app_data_reduced
    .groupby(["user_id", "session_id"])
    .agg(start=("timestamp", "min"),
         end=("timestamp", "max"))
    .reset_index()
    .assign(
        duration_mins=lambda _df: (_df["end"] - _df["start"]).dt.seconds/60
    )
)

sessions.head()

图

图 12.18 用户会话及其持续时间的快照(以分钟为单位)

这个 DataFrame 每行包含一个用户会话,因此按用户 ID 分组并计算会话平均时长,可以得到制作幻灯片图表所需的数据。以下代码执行此操作,并生成图 12.19 所示的图表:

fig, axis = plt.subplots()

avg_session_by_user = (
    sessions
    .groupby("user_id")
    ["duration_mins"]
    .median()
)

(
    avg_session_by_user
    .hist(bins=20, ax=axis)
)

axis.set(
    title="Distribution of users' average session length \
(one data point = 1 user)",
    xlabel="Session duration (minutes)",
    ylabel="Frequency"
)

plt.show()

图

图 12.19 用户平均会话长度的分布

这个结果告诉我们,大多数用户的会话时间不到 2 分钟,但有一个长尾。有些会话甚至超过 10 分钟。你可能注意到,这个图表与幻灯片中的图表并不完全一致,如图 12.17 和 12.19 之间的差异所示。这是因为我们选择删除“关闭”事件,因此已经与初始分析有所不同。这没关系,因为我们正在做出不同的假设,从而得出不同的结论。

如果有会话以“关闭”事件结束,我们的特定方法可能会稍微扭曲结果,因为我们低估了这些会话的时长。然而,由于我们的主要目标是观察人们在会话中使用了哪些类型的应用程序以及这些会话发生的时间,因此最初的“打开”事件就足够了。

我们已经重新创建了原始分析的相关部分,但在继续回答利益相关者的问题之前,让我们回顾一下这个过程。最新的图表如图 12.20 所示。

图

图 12.20 重新生成原始分析的过程

让我们继续分析的下一段,回答我们的利益相关者关于用户的新问题。

12.5.2 分析事件数据以了解客户行为

现在我们已经验证了原始分析的结果,我们可以继续回答利益相关者的新问题。第一个问题是关于人们何时使用手机,以及这是否会成为聚类的有用维度。

分析时间戳以了解浏览行为

为了调查人们何时使用手机,我们需要一个定义,即“使用时间”的定义。也就是说,我们关心每个数据点发生的时间吗?可能不是,因为时间上紧密相连的一组“打开”和“关闭”事件应该算作一个用户在那个时间使用手机的例子。

这意味着我们可以使用之前创建的会话级 DataFrame 中的开始时间。以下代码创建了一个列来提取日期的小时部分,这样我们就可以隔离浏览开始的那个小时。然后,我们绘制了这些“开始小时”的分布,结果如图 12.21 所示:

sessions["hour"] = sessions["start"].dt.hour

(
    sessions["hour"]
    .value_counts()
    .sort_index()
    .plot(kind="bar")
)

图

图 12.21 用户开始浏览会话的时间分布

这个图表告诉我们,大多数浏览会话在傍晚/晚上开始,在一天早些时候有所下降。但它没有告诉我们是否有用户的行为与这个总体趋势不同。为了做到这一点,我们需要计算每个用户最常见的开始小时或一天中的哪个部分。

注释:在本节中,我们将探讨数据的时序方面。如果你对处理时间序列数据的更多示例感兴趣,请参阅第八章和第九章。

让我们现在计算每个用户最常开始的小时。我们可以使用统计众数来做这件事,它简单地返回最常出现的值。然而,我们需要决定如果出现平局怎么办。如果某人的最常见浏览时间是早上 8 点和下午 5 点怎么办?没有理由偏好其中一个,所以我们将保留两个值。因此,该用户将被表示两次,这可能会通过过度代表某些用户来扭曲结果,但也会保留有价值的信息。

以下代码定义了一个函数,用于返回每个用户可能的多重众数值,并将其应用于 DataFrame。结果 DataFrame 的快照如图 12.22 所示:

def get_modes(group):
    mode_hours = group['hour'].mode()     #1
    return pd.DataFrame(
        {
            'user_id': group['user_id'].iloc[0],
            'most_frequent_hour': mode_hours
        }
    )

most_frequent_hours = (
    sessions
    .groupby("user_id")
    .apply(get_modes)     #2
    .rename(columns={"user_id": "duplicate_user_id"})
    .reset_index()
    .drop(columns=["level_1", "duplicate_user_id"])     #3
)

most_frequent_hours.head()

1 计算众数,可能返回多个值

2 应用此函数并提取所有最常开始的小时

3 清理由分组和聚合创建的不必要列

图

图 12.22 用户浏览会话最常见开始时间的快照

如图 12.22 所示,有用户有多个最频繁的开始时间的情况,例如用户 0,他们在下午 4 点和晚上 10 点浏览的频率相同。从这些数据中,我们现在可以查看新创建的most_frequent_hour列的分布,以了解是否有用户更喜欢一天中的不同时间使用他们的手机。以下代码调查了这一点,并生成了图 12.23 中的图表:

fig, axis = plt.subplots()

(
    most_frequent_hours
    ["most_frequent_hour"]
    .value_counts()
    .sort_index()
    .plot
    .bar(ax=axis)
)

axis.set(
    title="Distribution of most common starting time
↪ for users' browsing sessions",
    xlabel="Hour",
    ylabel="Frequency"
)

plt.show()

图

图 12.23 用户最常见浏览时间的分布

这个图表告诉我们,有不同群体的用户更喜欢在早上 6 点左右、中午 1 点左右或晚上 5 点左右浏览,还有一个高峰在午夜开始。

在我们决定如何在我们用户分割中使用这些信息之前,让我们将这个信息添加到我们不断增长的图表中,该图表如图 12.24 所示。

图

图 12.24 过程图,包括分析第二部分的开头

我们可以使用图 12.22 中显示的数据作为我们分割的一个维度,即用户实际最常开始浏览的小时。然而,会有两个问题:

  • 一些用户可能有多个行,这在分割问题中可能不起作用。

  • 一天中的某些小时非常相似,也就是说,用户在下午 4 点或 5 点开始浏览没有区别。

我们想要找到不同的用户群体,这样我们就可以将这些相似的时间分组在一起。我们本可以在一开始就做这件事,即把一天的不同时间段分组为“早上”、“中午”等等,并调查这些类别的分布。然而,我们会做出关于哪些小时在移动使用方面相似的假设。通过首先查看个别小时的分布,我们可以创建与我们在数据中找到的非常接近的组。

根据浏览模式创建客户标签

观察图 12.23 中的图表,我们可以得出结论,有两个到五个“峰值”,这些可以作为一天中的不同部分。使用多少个类别的选择将有些主观。让我们选择四个类别,因为这可能最适合数据,而不会在后续创建太多的用户细分。我们的群体将是

  • 夜猫子,通常在晚上 9 点到凌晨 3 点(含)之间浏览

  • 早晨用户,通常在凌晨 4 点到上午 9 点(含)之间浏览

  • 中午用户,通常在上午 10 点到下午 2 点(含)之间浏览

  • 晚间用户,通常在下午 3 点到晚上 8 点(含)之间浏览

让我们创建这些群体并调查每个群体中有多少用户。以下代码进行分类并在图 12.25 中生成输出:

bins = [-1, 3, 9, 14, 20]
labels = ['night_owl', 'early_morning_browser',
          'midday_browser', 'late_day_browser']

most_frequent_hours["category"] = (
    pd.cut(
        most_frequent_hours["most_frequent_hour"],
        bins=bins,
        labels=labels,
        ordered=True
    )
)

most_frequent_hours.loc[
↪ most_frequent_hours["most_frequent_hour"].isin([21, 22, 23]),
↪ "category"] = "night_owl"   #1

most_frequent_hours.head()

1 修复了额外的夜猫子值

figure

图 12.25 将用户最常浏览的时间分组为四个类别

现在,我们可以调查这些群体的成员资格。以下代码计算分布并生成图 12.26 所示的输出。

(
    most_frequent_hours["category"]
    .value_counts()
    .sort_index()
)

figure

图 12.26 用户浏览会话类别的分布

现在让我们使用这个数据集来创建这些类别的 one-hot 编码表示,即一个二进制列来指示每个类别的成员资格。关键的是,一个用户可以属于多个类别,如图 12.25 所示。更多实践中的例子请见第六章。以下代码创建 one-hot 编码并生成用户级别数据集的开始部分,我们将使用它进行细分。这个新数据集的快照如图 12.27 所示:

users = (
    pd.get_dummies(      #1
        most_frequent_hours.drop(columns=["most_frequent_hour"]),
        columns=["category"],
        prefix="time"
    )
    .groupby("user_id")
    .max()
    .reset_index()
)

users.head()

1 get_dummies 创建了 one-hot 编码的列。

figure

图 12.27 用户级别的数据快照,显示用户属于哪些浏览时间类别

我们现在创建了一个可以添加额外列并运行细分算法的数据集。在我们继续下一个利益相关者问题之前,即人们使用哪些类型的应用程序,让我们更新到目前为止完成的工作的图表。最新版本如图 12.28 所示。

figure

图 12.28 到目前为止的过程,包括根据浏览时间将人们分组到类别中

我们现在可以继续我们的下一个利益相关者问题,即人们使用哪些类型的应用程序。

使用人工智能进行数据标注

我们的利益相关者希望我们调查人们使用哪些类型的应用程序,并可能使用这些信息进行细分。然而,我们没有关于应用程序类型的直接数据,只有应用程序名称的列表。这是一个明显的例子,原始数据中包含的信息比我们最初想象的要多。领域专家可以轻松地将每个应用程序名称分类到更广泛的类别中。这是一个非常适合使用人工智能来增强我们工作的用例。

有超过 80 个不同的应用名称需要分类。手动操作虽然可行但很重复,所以让我们请一个 AI 来帮忙。我们可以使用以下代码片段来获取应用名称列表,然后传递给 AI 工具:

print(app_data_reduced["app_name"].unique())

以下提示,要求 ChatGPT 为我们分类应用名称,生成了图 12.29 所示的输出。

我将提供一个移动应用名称列表。尽你所能,将它们分组到逻辑类别中,例如,电子邮件、社交媒体、网络浏览器、移动游戏等。我希望以包含列'app_name''category'的表格形式得到响应。如果你不确定一个应用属于哪个类别,请填写“未知。”以下是应用名称列表:

figure

图 12.29 ChatGPT 对将应用名称分类到更广泛类别的一部分响应

注意:重要的是要注意,这些工具并不总是对相同的提示产生相同的输出。它们也在不断进化,所以你几乎肯定会得到与我相同的提示不同的输出。这是分析过程的一个特性,而不是一个错误。

一旦我们对输出满意,我们可以特别请求以 CSV 文件形式的数据,并将其合并到我们自己的数据中。以下代码实现了这一点,并在图 12.30 中展示了快照:

categories = pd.read_csv("./data/App_Categories.csv",
                         skiprows=1,
                         names=["app_name", "app_category"])
print(categories.shape)
categories.head()

figure

图 12.30 ChatGPT 生成的分类数据快照

让我们将这个最新的步骤添加到我们的流程图中,最新的版本如图 12.31 所示。

figure

图 12.31 我们步骤的最新图示,包括 ChatGPT 对应用分类

让我们现在看看这个类别数据的分布。以下代码计算了这一点,并在图 12.32 中产生了输出:

categories["app_category"].value_counts()

figure

图 12.32 新创建的应用类别分布

在这里有几个需要注意的事项。一是存在一些“未知”值,我们应该调查并手动分类。二是分类很多,可能太多以至于没有实际用途。首先,让我们解决“未知”值的问题。以下代码调查了这些值,并在图 12.33 中展示:

categories[categories["app_category"] == "Unknown"]

figure

图 12.33 ChatGPT 无法分类的应用名称

我们可以通过一些研究和一些代码片段手动对这些应用名称进行分类。以下代码更新了这些特定应用名称的分类:

categories.loc[categories["app_name"]=="MUIQ Survey App", "app_category"]
↪ = "Survey"
categories.loc[categories["app_name"]=="SurveyCow", "app_category"]
↪ = "Survey"
categories.loc[categories["app_name"]=="Reward Stash", "app_category"]
↪ = "Rewards"
categories.loc[categories["app_name"]=="Movie Play Box", "app_category"]
↪ = "Video Streaming"
categories.loc[categories["app_name"]=="MetroZone", "app_category"]
↪ = "Utility"

现在,让我们处理第二个问题,即 ChatGPT 创建了太多的类别。我们可以选择要求 ChatGPT 修订其分类并请求更少的列,或者我们可以使用 ChatGPT 的类别作为子类别,并自己定义主类别。这是一个个人选择,但这不应该花费太多时间,这样我们就有分类代码了,如果以后决定更改类别的话。

审查 AI 创建的数据标签

一个pandas技巧是使用map方法根据字典将类别映射到新的类别。我们不想自己输入这个字典,但我们可以编写一些代码来以所需格式打印类别,然后将它们复制并粘贴到我们的主要映射代码中。以下代码以正确的格式打印现有类别,并在旁边留出空间用于新类别。输出显示在图 12.34 中,因此您可以看到它如何粘贴以创建稍后跟随的代码片段:

for category in sorted(categories["app_category"].unique()):
    print(f"'{category}': '',")

figure

图 12.34 我们所需的 Python 字典格式的类别

现在,我们可以使用这个输出创建以下代码,该代码将更新每个现有类别及其新的分类。我们将保留现有的类别列作为子类别,因此我们有应用类别层次结构的两个级别。图 12.35 显示了这些更新后的类别数据集的快照。

categories = categories.rename(columns={"app_category": "app_subcategory"})

category_map = {
    'Advertising': 'Money',
    'App Marketplace': 'Entertainment',
    'Cloud Storage': 'Utility',
    'Communication': 'Social',
    'Email': 'Social',
    'Finance': 'Money',
    'Health': 'Social',
    'Messaging': 'Social',
    'Mobile Games': 'Entertainment',
    'Music Streaming': 'Entertainment',
    'Navigation': 'Utility',
    'News': 'Social',
    'Note Taking': 'Utility',
    'Online Shopping': 'Money',
    'Photo Editing': 'Utility',
    'Photo Management': 'Utility',
    'Podcasts': 'Entertainment',
    'Productivity': 'Utility',
    'Rewards': 'Money',
    'Social Media': 'Social',
    'Social Media/Messaging': 'Social',
    'Survey': 'Money',
    'Utility': 'Utility',
    'Video Streaming': 'Entertainment',
    'Web Browser': 'Browsing',
    'Web Browser/Search Engine': 'Browsing'
}

categories["app_category"]
↪ = categories["app_subcategory"].map(category_map)
categories.head()

figure

图 12.35 应用类别数据应用新分类后的快照

在我们将这些合并回主要应用数据之前,让我们通过更新我们的流程图来回顾我们的过程。最新版本显示在图 12.36 中。

figure

图 12.36 最新流程,包括对 ChatGPT 分类的回顾

现在,是时候将这些类别合并回我们的原始数据,看看人们如何使用这些各种应用类型了。以下代码将事件级应用数据与应用类别合并,创建合并数据,图 12.37 显示了其快照。

app_data_merged = app_data_reduced.merge(
    categories,
    on="app_name")
assert len(app_data_reduced) ==  len(app_data_merged)      #1
app_data_merged.head()

1 如果合并后我们没有相同数量的行,将引发错误信息

figure

图 12.37 应用数据与应用类别合并后的快照

从这些数据中,我们希望找到每个用户最常用的应用类别。之后,我们将调查是否存在平局,即拥有多个类别作为最常用类别的用户。

分析新创建的类别标签

以下代码计算每个用户的顶级类别,并在图 12.38 中显示了输出快照。请注意,pandas创建了一个名为level_1的列,因为mode函数可以返回多个值,就像我们在查看应用使用时间时做的那样:

top_categories = (
    app_data_merged
    .groupby("user_id")
    ["app_category"]
    .apply(pd.Series.mode)
    .reset_index()
)

top_categories.head()

figurefigure

图 12.38 “每个用户最常用类别”数据集的快照

从这些数据中,我们可以调查是否存在用户将多个类别作为他们最常用的类别。我们可以使用pandas创建的level_1列来过滤我们的数据。任何值为零的项意味着用户的顶级类别,因此大于零的值意味着用户有多个顶级类别。以下代码调查了这一点,并在图 12.39 中产生了输出:

top_categories[top_categories["level_1"] > 0]

figure

图 12.39 拥有多个顶级应用类别的用户

让我们更详细地检查这些用户,看看我们是否可以决定如何继续。以下代码检索这些用户的全部记录,并在图 12.40 中生成输出:

top_categories[top_categories["user_id"].isin([35, 223, 132])]

图

图 12.40 拥有多个顶级类别的用户的所有类别数据

我们可以为每个用户删除一个类别,或者以与我们处理应用程序时间相同的方式记录我们的数据,即用户可以拥有多个顶级类别。在这种情况下,只有三个受影响的用户,我们将调查是否可以删除他们的一个顶级类别。我们如何决定删除哪两个类别?让我们首先查看 ID 为 35 的用户的底层数据。以下代码检索该用户的单个应用程序级别的数据,然后显示在图 12.41 中:

(
    app_data_merged[app_data_merged["user_id"] == 35]
    .groupby(["app_category", "app_name"])
    .size()
)

图

图 12.41 用户 35 的按类别和应用程序名称的应用程序使用情况

这个用户非常频繁地使用设置应用程序,这导致了对于他们来说“实用”类别是并列的顶级类别。我们没有更多关于为什么这个用户持续打开他们的手机设置的原因的详细信息。我们可以使用我们所拥有的信息来决定用户最频繁使用“社交”应用程序是否更相关。我们将删除这个用户必要的记录,使其“社交”成为他们唯一的顶级类别。用户 132 也将“实用”作为他们的并列顶级类别,因此出于同样的原因,我们将删除该记录。以下代码清理了这两个用户的数据:

top_categories = (
    top_categories
    .drop(
        index=top_categories[
            (top_categories["user_id"].isin([35, 132]))
            & (top_categories["app_category"] == "Utility")
            ].index
    )
)

最后,让我们看看用户 223,并决定他们更适合“社交”还是“浏览”类别。以下代码查看他们的应用程序级别的数据,并生成图 12.42 中的输出:

(
    app_data_merged[app_data_merged["user_id"] == 223]
    .groupby(["app_category", "app_name"])
    .size()
)

图

图 12.42 用户 223 的应用程序级别使用数据

这个分类比较困难,因为他们大量使用社交应用程序,同时也频繁使用谷歌应用程序。然而,这可能意味着多件事,其中一些可能不适合“浏览”类别,因此我们将决定这个用户更适合“社交”标签。以下代码删除必要的记录。然后我们重新测试数据以验证没有用户有多个“顶级应用程序类别”记录:

top_categories = (
    top_categories
    .drop(
        index=top_categories[
            (top_categories["user_id"] == 223)
            & (top_categories["app_category"] == "Browsing")
            ].index
    )
)
top_categories["user_id"].value_counts().loc[lambda x: x > 1]

那个最新片段的输出是一个空列表,这意味着我们不再有拥有多个顶级应用程序类别的用户。我们现在可以创建聚类算法所需的指标变量。以下代码创建这些指标变量,并在图 12.43 中生成输出:

top_categories = (
    pd.get_dummies(
        top_categories,
        columns=["app_category"]
    ).drop(
        columns=["level_1"]
    )
)

top_categories.head()

图

图 12.43 我们“按用户顶级应用程序类别”数据的独热编码版本

这种格式的数据已准备好与我们在图 12.27 中创建的用户级别数据合并。在合并这两个数据集之前,让我们回顾一下到目前为止的进展。我们分析的最新图示显示在图 12.44 中。

图

图 12.44 我们分析的最新进展

我们已经调查了与我们的利益相关者问题相关的数据。现在,我们将合并最频繁使用的应用类别数据和最频繁的浏览时间数据,以构建我们的用户级数据集。我们还将创建并包含初始分析中的列,并准备好用于分割的用户级数据集。

创建用于分割的用户级数据集

到目前为止,我们有一个用户级数据集,其中包含人们倾向于使用手机的时间,以及另一个包含每个用户最频繁使用的应用类别。我们将它们合并在一起,然后添加其他指标。以下代码合并了这两个数据集,合并数据的快照如图 12.45 所示:

size_before = len(users)
users = users.merge(top_categories, on="user_id")
size_after = len(users)
assert size_before == size_after
users.head()

figure

图 12.45 到目前为止的用户级数据快照

初始分析包括每个用户使用的唯一应用数量和他们的浏览会话的平均长度,以分割用户。没有理由不保留这些指标,所以让我们重新创建它们并将它们添加到我们当前的数据集中。我们还将添加“会话数量”作为用户如何频繁使用手机的代理,因为否则,使用方面的这一方面在我们的任何变量中都没有被捕捉到。

首先,我们可以直接从原始应用数据中添加与应用数量和会话数量相关的列。以下代码计算每个用户的唯一应用和会话数量,并生成一个数据集,其快照如图 12.46 所示:

user_metrics = (
    app_data_merged
    .groupby(["user_id"])
    .agg(
        number_of_apps=("app_name", "nunique"),
        number_of_sessions=("session_id", "nunique")
    )
    .reset_index()
)

user_metrics.head()

figure

图 12.46 计算每个用户唯一应用和会话数量的用户级数据集

现在让我们将此表与我们在图 12.45 中创建的数据集结合起来。以下代码实现了这一点:

size_before = len(users)
users = users.merge(user_metrics, on="user_id")
size_after = len(users)
assert size_before == size_after

要添加平均会话长度,我们需要转向我们在 12.5.1 节中早期创建的会话级数据。我们将按用户 ID 对其进行分组,并计算每个用户的会话中值持续时间。以下代码执行此操作并生成一个数据集,其快照如图 12.47 所示:

avg_sessions = (
    sessions
    .groupby("user_id")
    .agg(avg_session_length=("duration_mins", "median"))
    .reset_index()
)

avg_sessions.head()

figure

图 12.47 每个用户的平均会话持续时间

在分割之前,我们的最后一步是将此表合并到主要用户级数据中。以下代码执行此操作,图 12.48 显示了我们将用于分割的数据中的所有列:

size_before = len(users)
users = users.merge(avg_sessions, on="user_id")
size_after = len(users)
assert size_before == size_after

users.columns

figure

图 12.48 我们将用于分割用户的所有列

让我们将此用户级数据直接导出到下一章的文件夹中,这样我们就可以直接从这个数据集开始分割过程:

users.to_parquet("../chapter-13/data/users.parquet", index=False)

在进入下一章的分割部分之前,让我们回顾一下到目前为止我们所做的一切。

12.5.3 到目前为止的项目进度

让我们回顾一下这个项目的进度:

  • 我们首先调查了初始演示背后的工作,并验证了所展示的总结统计信息。

  • 我们通过深入分析用户行为继续分析,并得出结论,有不同时间使用手机的人群群体。

  • 我们使用生成式 AI 将应用名称分类到更广泛的应用类别中。

  • 我们根据用户最常使用哪些应用来识别不同的用户行为,并使用这些应用类别。

  • 最后,我们导出了一个准备在下一阶段进行分段的用户级数据集。

图 12.49 显示了到目前为止的整个过程。

figure

图 12.49 分段开始前的分析部分 1 和 2

在下一章中,我们将使用导出的用户级数据将用户划分为不同的组,以回答利益相关者的问题。

摘要

  • 继续别人的工作从重新追踪他们的步骤开始。

  • 重新创建初始分析有助于我们理解问题并找出任何错误。

  • 在继续别人的工作时,应该检查和验证他们的假设和决策。

第十三章:13 在他人工作的基础上迭代:客户细分

本章涵盖

  • 根据行为数据细分用户

  • 评估聚类算法的输出

在本章中,我们将使用上一章创建的数据集,根据用户的移动浏览行为将用户分组。在第十二章中,我们接管了另一位分析师的工作,验证了他们的发现,并继续分析我们客户的行為。我们将事件级数据集转换为用户级数据集,在本章中,我们将应用聚类算法来创建不同的用户细分。我们将评估这些细分并解决利益相关者的疑问。在继续之前,让我们回顾一下项目。

13.1 重访项目 8:从移动活动中寻找客户细分

回顾一下,我们为 AppEcho Insights 工作,这是一家专注于移动用户行为的分析公司。他们分析用户如何使用手机的数据,并向手机制造商和应用程序开发者提供见解。

他们想了解是否存在使用手机方式相似的用戶群体。了解这些用户细分将有助于他们的客户能够通过不同的倡议来针对整个用户基础。他们希望关注以下方面:

  • 人们使用的应用类型。例如,是否有些客户比其他客户更倾向于使用社交媒体?

  • 时间模式。用户是否喜欢在一天中的不同时间浏览他们的手机?

  • 使用更合适的细分方法(例如,可以处理基于多个维度分组用户的算法)。

我们在第十二章中回答了他们前两个问题,其中我们研究了时间模式和应用程序使用差异。

数据可在davidasboth.com/book-code上找到,您可以使用这些数据尝试自己完成项目。您将找到可用于项目的文件,包括最初分析师的幻灯片,以及以 Jupyter 笔记本形式的示例解决方案。

我们现在准备应用聚类算法来解决 AppEcho 的最终点并创建更复杂的客户群体。

13.1.1 数据字典

作为提醒,他们提供了一套移动事件数据集以及最初分析师所做的演示。该数据集包括单个移动事件,例如用户打开或关闭移动应用。列数不多,但可以提取丰富的行为信息。事件都有时间戳,因此时间元素也可以进行分析。有关处理时间数据的更多详细信息,请参阅第八章和第九章。

注意:原始数据来自github.com/aliannejadi/LSApp。感谢 Mohammad Aliannejadi 允许使用这些数据。

一个关键初始步骤是查看可用的数据。表 13.1 显示了数据字典。

表 13.1 数据字典
定义
用户 ID 一个用户的唯一标识符。
会话 ID 唯一标识用户的会话活动。
时间戳 单个事件的日期和时间。
应用名称 正在使用的应用的名称。
事件类型 发生的事件类型。值是以下之一:“打开”、“关闭”、“用户交互”或“损坏”。

让我们再回顾一下这个项目的预期成果。

13.1.2 预期成果

我们的利益相关者希望进行更深入的分析,我们已经完成了,并且基于更多维度的客户细分,这是本章的重点。最终输出是我们用户细分的定义,即哪些因素描述了每个细分,哪些用户属于哪个群体。

在继续分析之前,让我们回顾一下到目前为止的工作。

13.1.3 到目前为止的项目总结

在上一章

  • 我们调查了初始演示背后的工作,并验证了前分析师提供的摘要统计信息。

  • 我们通过深入研究用户行为继续分析,并得出结论:有不同时间使用手机的不同人群群体。

  • 我们使用生成式 AI 将应用名称分类到更广泛的应用类别中。

  • 我们使用这些应用类别根据用户最常使用哪些应用来识别不同的用户行为。

  • 最后,我们导出了一个用户级数据集,为下一阶段的细分做好了准备。

图 13.1 显示了到目前为止的工作。

figure

图 13.1 上一章项目中完成的工作

现在我们可以使用上一章导出的用户级数据集来创建用户细分。在本章中,我们将选择一个聚类方法,应用它,并在确定结论和下一步之前评估结果。

13.1.4 使用聚类对移动用户进行细分

回顾一下,在我们的分析版本中,我们将根据以下维度对用户进行细分:

  • 使用独特应用的数量(作为使用多样性的代理)

  • 独特使用会话的数量(作为使用频率的代理)

  • 用户会话的平均持续时间

  • 他们使用手机的时间段

  • 他们最常使用的应用类型

这些维度在我们的数据中总共组成 12 列。数据集有 13 列,但其中一列是用户 ID,它是一个任意的整数,不会用于聚类。我们将采取以下步骤来细分用户:

  1. 将我们的数据转换,使所有列都在相同的尺度上

  2. 选择一个聚类算法

  3. 将聚类算法应用于将每个数据点分配到簇中

  4. 评估输出

让我们读取上一章创建的用户级数据。图 13.2 显示了数据的样本:

import pandas as pd
import matplotlib.pyplot as plt
users = pd.read_parquet("./data/users.parquet")
users.head()

figure

图 13.2 上一章创建的用户级数据快照

在应用聚类算法到数据之前,我们需要检查我们的数据是否处于相同的尺度。图 13.3 展示了我们用户级数据中的列的提醒。

figure

图 13.3 我们用户级数据中可用的列

我们有一些变量处于不同的尺度。指标列只能为零或一,而像会话数量这样的列可能达到数千。这给基于距离的聚类方法带来了问题。

将变量转换为同一尺度

聚类意味着找到彼此靠近的数据点。彼此“靠近”的定义需要一个距离的数值测量。如果我们的某一列的尺度比其他列大,那么该列将主导我们的聚类。在我们的例子中,如果我们使用原始数据,最可能的情况是用户将仅根据会话数量进行分割,因为这是变化最大的列。如果所有列都在大约相同的范围内,它们将对聚类贡献相等。

我们可以使用不同的缩放方法。一种典型的方法是将数据点转换为 z 分数,这意味着它们将通过距离,以标准差为单位,来衡量该列的平均值。所有以这种方式缩放的数据将大约在-5 到 5 的范围内,无论其基本单位如何。如果一个用户在转换后“会话数量”的值为 1,这意味着他们的会话数量比用户平均会话数量高出 1 个标准差。然后,这个值可以直接与“应用数量”的 1 进行比较,尽管这两个度量可能处于不同的尺度。

无论我们选择哪种方法进行缩放,我们只会将其应用于我们的连续数据,而不是指标列,因为那些列已经处于可比较的尺度。以下代码将我们的原始数据转换为缩放版本,图 13.4 显示了前几行。注意,图中的数据已转置,以便更容易看到所有 12 列的值:

from sklearn.preprocessing import StandardScaler

scaler = StandardScaler()      #1

X = users.drop(columns=["user_id"])

continuous_features = ['number_of_apps',
                       'number_of_sessions',
                       'avg_session_length']

X_scaled = X.copy()
X_scaled.loc[:,continuous_features]
↪ = scaler.fit_transform(X[continuous_features])    #2

X_scaled.head().transpose()    #3

1 创建一个缩放器对象

2 仅转换连续列并覆盖它们的值

3 显示了为了可读性而转置的前五行

figure

图 13.4 准备进行聚类的转换数据的几行

注意,指标列仍然包含二进制值;只有最后三个连续列的值已被缩放。在选择并应用聚类算法之前,让我们回顾一下到目前为止的过程,并添加缩放步骤。图 13.5 显示了最新的图表。

figure

图 13.5 到目前为止的过程,包括分割的开始

现在我们已经准备好选择并应用一个聚类算法来分割我们的用户。

选择并应用聚类算法

选择聚类算法取决于可用的数据。一些聚类方法在大数据集上比其他方法表现更好,而其他方法在维度很多时表现更佳。一些方法需要事先选择簇的数量,而有些方法会根据数据自行确定簇的数量。与大多数数据分析方法一样,不同的方法都有其优缺点。有关这些优缺点的更多信息,请参阅 scikit-learn 文档:mng.bz/MD5W

对于这个解决方案,我们将保持简单,并使用最流行的 k-means 算法。这是一种基于距离的方法,如果数据空间中的点彼此靠近,它们就会被分组到同一个簇中。它试图找到一个解决方案,使得簇内的点尽可能接近,但簇之间尽可能远。这意味着组内部应该尽可能同质,但其他方面则应彼此不同。让我们将这个决策添加到我们不断发展的图表中,其最新版本如图 13.6 所示。

figure

图 13.6 我们最新的进展,包括聚类算法的选择

k-means 等聚类算法的一个重要组成部分是我们需要事先选择组数。这并不简单,因为我们可能对我们的当前案例没有合适的直觉。找到簇数量合适范围的一种方法被称为“肘部方法”。这意味着通过经验尝试不同的组数,并观察添加另一个组带来的好处何时开始减少。

我们将运行不同数量的组的 k-means 聚类算法,并测量一个称为“惯性”的指标,该指标衡量点与其簇中心之间的接近程度。惯性值低意味着聚类结构更好。然而,请注意,增加更多的组总是会降低惯性,因为拥有更多的簇中心意味着点更有可能接近其中一个。通常,我们会找到添加另一个簇不会显著降低惯性分数的点。

注意:在现实中,像聚类这样的无监督方法往往是主观评估的。相较于优化诸如惯性这样的数值指标,更重要的是考虑这些聚类是否适合我们的用例。

“肘部方法”的概念最好通过视觉方式来解释。以下代码从 3 个组运行到 15 个组,并绘制了相关的惯性分数,如图 13.7 所示:

from sklearn.cluster import KMeans

k_values = range(3, 15)
inertia_values = []

for k in k_values:
    kmeans = KMeans(n_clusters=k, random_state=42)
    kmeans.fit(X_scaled)
    inertia_values.append(kmeans.inertia_)

fig, axis = plt.subplots()

axis.plot(k_values, inertia_values)

_ = axis.set(
    title="'Inertia' values for different values of k",
    xlabel="Number of clusters (the 'k' in k-means)",
    ylabel="Inertia"
)

axis.set_ybound(0, 900)

plt.show()

figure

图 13.7 使用不同数量的组进行聚类的结果

这显示了这样一个典型场景,当聚类数量较少时,添加一个聚类会显著降低惯性值,但这种模式不会持续。在图 13.7 中,从七个聚类增加到八个聚类几乎不会降低惯性分数。大多数时候,这些图表看起来像手臂,我们在寻找肘部,即曲线斜率平直的点。在图 13.7 的案例中,我们将使用七个作为拐点,因此从七个聚类开始。图 13.8 展示了包含这个最新决定的流程。

figure

图 13.8 选择聚类数量后的最新流程

让我们应用 k-means 聚类算法到我们的数据中,选择创建七个聚类。我们将使用 scikit-learn 来完成这个任务,这是流行的 Python 机器学习库。我们将创建一个 k-means 聚类对象,将其应用于数据,并在我们的用户表中创建一个新列,记录每个用户属于七个聚类中的哪一个。以下代码完成了这个任务,图 13.9 展示了增强后的用户表快照。再次强调,数据已经转置,这样我们可以看到所有列的值:

kmeans = KMeans(n_clusters=7, random_state=42)
clusters = kmeans.fit_predict(X_scaled)
users["cluster"] = clusters

users.head().transpose()

figure

图 13.9 带有附加聚类分配的五行用户数据

图 13.9 显示了初始用户数据,包括所有维度以及新的聚类分配。它告诉我们用户 0 被分配到聚类 3,用户 1-3 被分配到聚类 6,依此类推。我们将使用这个新列来评估结果。

评估聚类结果

一旦聚类完成,我们想要评估我们的结果,这意味着以下内容:

  • 调查每个聚类的中心(即,每个组中的典型用户是什么样的?)。

  • 观察每个聚类的值分布(即,一个组中所有用户的典型特征是什么?)。

  • 理解聚类是否彼此不同。我们不希望创建两个相似的聚类。我们希望每个组在某种程度上都是独特的。

首先,让我们看看每个聚类中有多少用户。以下代码完成了这个任务,并在图 13.10 中产生了输出:

users["cluster"].value_counts().sort_index()

figure

图 13.10 每个聚类的用户数量

我们立即注意到有两个聚类中每个聚类只有两个或三个用户。这意味着这些用户可能确实与其他用户不同,或者我们需要更少的聚类。k-means 类似的方法的一个缺点是,要求它创建七个聚类将会创建七个聚类,无论这是否有根据。让我们看看聚类的中心,这告诉我们每个组的平均值,我们可以将其用作每个细分市场典型用户外观的代理。

我们需要记住做的一件事是反转我们之前对连续列所做的转换,以便集群中心与我们的输入数据处于相同的尺度。否则,应用程序数量、会话数量和平均会话长度都将大约在-3 到 3 之间。

以下代码执行了这种反转转换,并打印出每个集群的中心值,如图 13.11 所示:

import numpy as np

original_cluster_centers
↪ = np.copy(kmeans.cluster_centers_)    #1

cluster_centers = pd.DataFrame(
    data=original_cluster_centers,
    columns=X_scaled.columns
)

cluster_centers.loc[:,continuous_features]
↪ = scaler.inverse_transform(original_cluster_centers[:,-3:])     #2

cluster_centers.transpose().round(2)

1 复制集群中心,以防止意外修改

2 仅反转连续列的转换,保留二进制列不变

figure

图 13.11 集群中心和它们相关的属性

这表明,例如,从第一列向下阅读,在集群 0 中,48%的用户是夜猫子,其中 76%的用户主要用手机进行社交目的,平均有 107 个独特的会话。

集群 4 和 5 只有两到三个用户。看起来集群 4 之所以是一个独立的集群,其中一个原因是它是极端用户最多的地方,因为平均会话数超过 3,000。集群 5 看起来代表的是会话长度最长的用户,平均为 9.79 分钟。基于这些发现,我们将决定保留所有七个集群,因为它们似乎代表了不同类型的用户。

下一步是为每个组分配一个角色来描述他们。例如,集群 1 包含会话数量多且应用数量高的用户;他们主要用手机进行社交媒体活动,并且是晚上的或晚上的浏览器。这似乎是一个独特的角色:一个人下班回家后,大部分时间都在社交。

另一个明显的角色将是集群 2,它包含更多消费者用户。他们的总会话数量很少,通常很短;他们主要用手机进行浏览,平均只使用七个不同的应用程序。集群 6 的用户在白天浏览更多,但平均会话时间较短。也许他们是那些在午休时间使用社交媒体应用程序的人。让我们总结一下可能的角色。这个总结显示在表 13.2 中。

表 13.2 用户角色总结
角色名称 角色特征
0 典型社交媒体用户 会话数量平均,主要用于社交媒体,大多数在白天晚些时候和晚上使用
1 重度用户 会话和应用程序使用数量高,主要是社交媒体,大多数在白天晚些时候和晚上使用
2 消费者用户 会话和应用程序数量少,使用会话短,主要用于浏览
3 夜猫子 所有夜猫子,主要使用社交媒体
4 使用异常 在整个期间平均超过 3,600 个会话
5 会话长度异常 每次使用会话平均约 10 分钟
6 晚上社交用户 会话数量平均,使用会话短,主要用于社交媒体,大多数在白天晚些时候

从这个表格中,我们可以根据他们的统计数据为每个群体分配一个角色,但我们可能认为有一些群体相似,可以合并。例如,聚类 0 和 6 并没有太大的区别。你可能在向利益相关者展示之前做一些额外的合并,或者这可能是根据他们的意见来完成的。无论如何,这类主观评估是我们确定在这个案例中七个聚类是否合适的方法。

在总结我们的发现并讨论下一步计划之前,让我们回顾一下我们的整个流程。我们从重新创建初始分析到执行我们自己的分析,最后创建新的客户细分。图 13.12 展示了整个过程的可视化。

图

图 13.12 整个过程的示意图

现在我们总结我们的发现并决定下一步计划。

13.1.5 结论和下一步计划

最后,添加利益相关者感兴趣维度使我们能够创建越来越复杂的用户细分。这些构成了我们的最小可行答案。重要的问题是我们的发现是否会引导行动。企业可以用这些客户细分做什么?因为公司的商业模式是将这些见解卖给移动制造商和应用程序开发者,我们可以想象他们可能使用这些信息的一些方式:

  • 有一个更精确的方式来定义“重度用户”可能有助于为过度使用手机的人创造干预措施。

  • 了解“偶然”用户意味着更好地定位中间内容(例如,如何充分利用你的手机)。

  • 为那些主要使用手机进行社交媒体的人设立单独的用户细分可能导致对其他社交媒体应用程序的更好定位。

这个项目的一个重要方面是,我们的聚类完全基于我们的决策。我们的数据处理选择,变量的选择,以及聚类算法的选择都为最终解决方案做出了贡献。任何这些决策的变化都会产生不同的分组。这就是为什么与利益相关者的合作如此重要的原因。他们可以帮助做出创造最相关结果的决定。

演示技巧

作为分析师展示你的结果是一种随着实践而来的技能。这需要与真实环境(即,在分析他们关心的情况下)中的人们进行许多互动。做好这项工作所需的能力包括:

  • 了解你的受众,他们的动机,他们现有的理解,等等

  • 与你的受众产生共鸣并调整你的信息

  • 以相关的方式总结你的发现

  • 讲述你的分析故事

最后一点广泛地属于“数据讲故事”这一概念,它包含了一系列相关技能。更多信息,请参阅 Kat Greenbrook(Rogue Kororaˉ,2023)所著的《数据讲故事手册》。

提高这些技能的最好方法是向相关受众展示你的结果。从受众那里获得实时反馈对于提高演讲技巧极为宝贵。另一种相关的方法是在聚会和会议上发表演讲。这将帮助你自信地传达你在某个主题上的专业知识。最后,建立一个项目组合在讨论招聘经理时可能很有价值。

关于额外的工作?尽管列数不多,但数据足够丰富,可以进行进一步的迭代。一个想法可能是推断用户的移动操作系统。如果他们使用了谷歌应用,他们可能在使用安卓设备,而使用苹果应用则意味着他们使用的是 iOS。这种区别可能会改变我们的用户细分结构。

我们也没有充分利用数据的个体事件级方面。我们可以查看用户在单个应用上花费的时间、应用打开和关闭的次数、人们切换应用频率的变化等。所有这些考虑都可以为我们提供更丰富的用户细分来探索。

数据的一个注意事项,我们多少回避了,就是会话的开始和结束并不干净利落。有时,一个会话以“打开”事件结束,紧接着几分钟后又开始了另一个会话。如果我们想更详细地探索会话和事件,我们可能需要更深入地清理这些会话。

活动:使用这些数据的进一步项目想法

想想你可以用这个数据集进行的其他分析。以下是一些你可能想要考虑的方向:

  • 不同人的应用级行为是什么样的?人们是否使用不同的应用方式不同?

  • 每个类别中最受欢迎的应用是什么?

  • 我们用户群中对于新应用的使用率如何?有没有方法通过外部数据源增强你的数据以更好地回答这个问题?

13.2 结束语:细分和聚类

在这个项目中练习的用户细分技能适用于各种现实场景。细分在许多领域都会出现。无论你想找到相似的客户、微波炉还是农作物,其基本思想都是相同的。

为了为未来的任何聚类项目做好准备,了解以下内容是有用的

  • 一些不同的聚类算法以及每种算法在什么场景下更有用

  • 无监督机器学习的预处理技术,例如我们在本项目中执行的标准化

  • 评估指标,如我们之前测量的惯性得分,或更高级的概念,如轮廓得分

  • 我们决定一组聚类是否适合特定业务案例的主观评估过程

至少对这些概念有一个表面上的熟悉度意味着,当你在现实生活中遇到相关项目时,你将能够识别聚类作为适当的方法,并深入研究你需要产生最小可行答案的方面。

总的来说,最好的方法是通过实践来磨练这些技能,所以如果你在生活中遇到一个聚类是相关方法的问题,就把它作为一个机会来检验你的技能。

13.2.1 用于任何项目的技能学习

让我们回顾一下这个最新项目的各个方面以及完成它所需的技能。为了成功继续他人的现有分析,可以应用于任何类似项目的关键技能包括

  • 核实现有计算(例如,对于报告的汇总指标,如平均值)

  • 如果有现成的分析代码,重新运行以验证结果

  • 记录项目上以前分析师所做的假设和决策

要将记录,如客户,分割成不同的组,关键技能,适用于任何此类项目,包括

  • 创建一个合适粒度的数据集(例如,如果细分用户,则使用用户级别的数据集)

  • 将输入到聚类算法中的数据进行转换,使其都在同一尺度上

  • 选择合适的聚类算法

  • 使用合适的工具在数据上运行所选的聚类算法

  • 对结果进行数值评估(例如,通过查看惯性得分)和主观评估(例如,从领域专业知识的角度看,是否聚类有意义)

摘要

  • 分段/聚类算法在数据问题上有广泛的应用。

  • 我们所做的决策,例如聚类数量或用于确定它们的变量,将彻底改变聚类算法的结果。

  • 如用户细分这样的任务需要主观的人类评估。

  • 给出聚类个性有助于识别其关键特征。

附录 Python 安装说明

书中的项目对技术没有特定要求,示例解决方案主要关于过程,而不是 Python 的具体细节,Python 是我的首选技术。然而,如果你像我一样是 Python 用户,你可能想在你的机器上重新创建我的结果,并将我的示例解决方案作为起点。本附录解释了如何安装 Python 并设置它,以便模仿示例解决方案的设置。

通常,项目所需的 Python 库列在配套的 Jupyter 笔记本中,可以从import语句中推断出来。也就是说,如果解决方案中的代码导入了pandas,你需要安装pandas库。但是,为了精确地重新创建我的示例,你需要每个库的相同版本,因为功能在不同版本之间有所变化。确保你的 Python 环境设置与我相同的方法有很多,但通常这是通过虚拟环境来完成的。

注意:为了在书中重新创建解决方案,并不需要完全相同的设置。你很可能可以使用更新的 Python 版本和更新的库版本,如pandas,并获得相同的结果。然而,虚拟环境明确地锁定到较旧的 Python 版本和必要的库版本,以确保它们之间的兼容性。例如,第三章中使用的recordlinkage库在撰写本文时与pandas版本 2.0 不兼容。

虚拟环境允许你在同一台机器上拥有多个 Python 库的组合,甚至可以是不同版本的 Python,通常每个项目一个。我建议创建一个虚拟环境,在其中你可以运行配套的代码示例,并确保你的库与我的版本相同。再次强调,设置虚拟环境的方法有很多。你可能已经有了自己偏好的方法,或者你可能之前从未遇到过虚拟环境。在这本书中,我使用了poetry库,接下来的章节将包括如何重新创建我的环境的说明。

A.1 安装 Python

具体来说,书中的项目使用 Python 3.11,但这只在你想要精确地重新创建示例解决方案时是必需的。如果你已经安装了不同版本的 Python,但想重新创建我的环境,你可以安装 Python 3.11,因为它将与其他 Python 安装分开。

您可以直接从 Python.org 安装 Python (www.python.org/downloads/),或者通过捆绑版本,例如 Anaconda (www.anaconda.com/download)。您还可以通过 Anaconda 的最小版本,如 Miniconda (docs.conda.io/en/latest/miniconda.xhtml) 或 Miniforge (github.com/conda-forge/miniforge) 来安装 Python。

我个人使用 Miniforge,但只要您在机器上安装了 Python 3.11,它从哪里来并不重要。

A.2 安装 poetry

poetry 是我选择的用于管理虚拟环境的包和依赖管理工具。您可以使用您拥有的任何内置包管理器(通常是 pipconda)与您的 Python 安装一起安装它。一个示例命令,应在终端或命令提示符中运行,是 pip install poetry

注意:如果您使用不同的虚拟环境管理方法,例如 virtualenv,我还包括了一个 requirements.txt 文件,它包含与 poetry 文件相同的信息,但它是这些其他工具所需的格式。这仍然需要您使用 Python 3.11。

关于 poetry 的更多信息可在 python-poetry.org/ 找到。

A.3 创建您的虚拟环境

在本书的材料中,我提供了两个文件,poetry.lockpyproject .toml,您需要使用所有必要库的相同版本来重新创建我的虚拟环境。在撰写本文时,您只需在您下载本书代码的根目录中拥有这两个文件即可。

首先,导航到包含这些文件的代码文件夹,并运行以下命令以确保 poetry 使用 Python 3.11,无论您的机器上存在其他版本。图 A.1 展示了一个命令提示符的示例:

poetry env use /path/to/your/python3.11/python.exe

figure

图 A.1 显示了告诉 poetry Python 3.11 安装位置的命令

接下来,运行命令 poetry install 以创建您的虚拟环境。将安装必要的库的正确版本。图 A.2 展示了输出可能的样子。

figure

图 A.2 安装 poetry 环境及其相关库

到此为止,您已经设置了一个与我的设置相同的虚拟环境。您可以通过运行命令 poetry shell 来激活它。

这将激活环境,并且任何后续的 Python 命令都将在此环境中运行而不是在基本环境中。从这里,您可以使用命令 jupyter notebook 启动 Jupyter。这将启动 Jupyter,您可以使用正确版本的 Python 和其库与代码进行交互。图 A.3 展示了启动 Jupyter 之前的最终 poetry 命令。

图

图 A.3 从poetry环境启动 Jupyter
posted @ 2025-11-23 09:27  绝不原创的飞龙  阅读(17)  评论(0)    收藏  举报