集装箱化数据管道的阿帕奇气流
在 Airflow 上使用不同版本的 Python 运行任务时是否会遇到问题?在本文中,我将解释如何解决这个问题。
弗兰克·麦肯纳在 Unsplash 上拍摄的照片
介绍
您可能以前听说过 Apache Airflow,或者您现在正在使用它来调度您的数据管道。而且,根据您要运行的内容,您的方法是为它使用一个操作符,您使用 SparkSubmitOperator 调度 Spark 作业,并使用 BashOperator 在 Airflow 正在运行的机器上运行一些东西。当您需要运行 Python 代码时,事情就变得有趣了。
您可以再次查阅文档,找到著名且有用的 PythonOperator。很直白。您只需将想要运行的 Python 代码放在那里,它将根据您的 DAG 的配置运行。您只需要确保您的代码运行在 Python 版本的 Airflow 上,并且您的库安装在系统上。
您的工作流使用 Python 的次数越多,您使用 PythonOperator 的次数就越多。您很高兴,因为您能够将 Python 知识用于所有数据科学任务。Airflow 允许您在其中安装 Python 模块,因此您可以手头上有所有您喜欢的库来做非凡的事情。
在您或您团队中的某个人想要使用新版本的 Python 或新的库之前,所有这一切都运行良好,此时您已经有许多任务在运行旧版本且不兼容的库,您有两个选择:
- 更新库并更新所有有问题的任务。
- 根据所有现有任务使用库的过时版本。
让我们更深入地了解这些选项,并分析第一个选项。您已经有 30 个任务在使用旧版本的库,因此您和您的团队可以抽出时间,用这些更改来更新库和所有这些作业。这不是一个简单的任务,但你们都做到了。最后,这是值得的,因为您有机会使用库的所有新特性,并且您的代码库是与最新趋势同步的。但是这是不可伸缩的,当你有 100 个任务在运行,你需要做同样的事情,会发生什么呢?或者仅仅是您的旧任务出于某种原因需要旧版本的库?
现在我们来分析第二种选择,这种选择似乎不那么痛苦。你用一个过时版本的库开发你的任务,然后就结束了。这并不理想,但也许你知道如何用过时的版本完成工作。但同样,这是不可扩展的,一两年后,你继续用一个过时的版本开发,你将面临各种问题,其中之一是你将需要一些新的东西,在这一点上用过时的版本库很难完成,而且你做不到。这真的会挫伤积极性,并且会影响到你和你的团队。最终,你的代码库将不会是一个你可以创新和尝试新事物的快乐地方。
现在让我们想象一个更糟糕的场景,当我们需要更新系统的 Python 版本或者我们的一个库与 Airflow 本身不兼容时。这是一个主要的问题,而且非常普遍,尤其是当团队在成长的时候。
那么,我们能做些什么来解决这个问题呢?
使用容器
我们之前问题的解决方案是使用 Docker 容器。
气流调度集装箱化任务—作者图片
想象一下,您可以创建一个 DAG,其中每个任务都在一个 Docker 容器中运行,由于容器的原理,您需要安装任何版本的库或您想要用于它的语言。
这将使您能够自主地使用新技术创建任务,而无需承担更新已经运行的任务的负担。
但是我如何才能做到这一点呢?
让我们探讨几个选项。
在 Kubernetes 上安排作业
KubernetesPodOperator 的气流调度任务—图片由作者提供
如果你不知道 Kubernetes 是什么,你可以去这里。
通过这种方法,我们在 Kubernetes 中以 pod 的形式推出了 Docker 容器。这些吊舱是短暂的,这意味着一旦任务完成,吊舱被摧毁,它不会使用系统的容量。
因此,每个任务都是一个 Docker 容器,每个容器都作为一个 Pod 在 Kubernetes 上启动。Airflow 将日志从 Kubernetes 拉到任务的 UI 中,因此任务在哪里运行对您来说是透明的,最后您可以继续正常使用 Airflow。
为此,我们使用了 KubernetesPodOperator 。
[KubernetesPodOperator](https://airflow.apache.org/docs/apache-airflow-providers-cncf-kubernetes/stable/_api/airflow/providers/cncf/kubernetes/operators/kubernetes_pod/index.html#airflow.providers.cncf.kubernetes.operators.kubernetes_pod.KubernetesPodOperator)
使用 Kubernetes API 在 Kubernetes 集群中启动一个 pod。通过提供一个图像 URL 和一个带有可选参数的命令,操作员使用 Kube Python 客户机来生成一个 Kubernetes API 请求,该请求可以动态启动这些单独的 pod。用户可以使用config_file
参数指定一个 kubeconfig 文件,否则操作员将默认为~/.kube/config
。
这是我发现的更通用的解决方案,可以在主要的云提供商甚至本地安排容器化的任务。Kubernetes 是作为 Azure、AWS、Google Cloud、Linode 等平台上的服务提供的。可能是库伯内特公司的解决方案。
在 GKE 上安排工作(Google Kubernetes 引擎)
GKE 上的气流调度任务——图片由作者提供
如果你在谷歌云上,你计划使用的 Kubernetes 集群是托管版本(GKE)。为了方便起见,合适的操作员是gkestartpoperator。你可以使用 KubernetesPodOperator,但最终需要更多的工作来设置所有的环境,如果你在谷歌云上,我认为这是推荐的方法。
ECS(弹性集装箱服务)上的作业调度
ECS 上的气流调度任务—图片由作者提供
现在让我们假设你在 AWS 上,他们提供了一个叫做弹性库本内特服务或 EKS 的解决方案。如果你要在那里启动任务,你可以使用 KubernetesPodOperator。
但是 AWS 提供了另一种容器编排服务,称为弹性容器服务或 ECS,正如你可能已经猜到的,其中有一个操作符来启动容器化的任务,它被称为 ECSOperator 。
在 ECS 上,没有 pod,这个概念有点不同,在那里它们被称为任务。您将在 ECS 任务中安排气流集装箱化任务。我知道,这听起来令人困惑,但原理和豆荚是一样的,它们是短暂的。因此,一旦气流任务完成,它就不会消耗资源。
考虑
- 当您在群集中运行任务时,请确保您的群集拥有所有可用的权限和凭据。如果集群与 Airflow 运行的集群不同,并且您正在迁移任务,这是您需要考虑的问题。
- 运行任务所需的额外 Kubernetes 或 ECS 群集的成本不容小觑。你一定要考虑到这一点。
- 一个是我们之前用来在 Kubernetes 上运行任务的 KubernetesPodOperator,另一个是 Kubernetes Executor,最后一个是使用 Kubernetes 来运行 Airflow 本身。请确保你不要混淆这些,有时网上有很多信息可能会使这种情况发生。
- 您不一定需要在 Kubernetes 或 ECS 上运行 Airflow 本身,您可以在外部集群中调度任务。
结论
这不是银弹,因为在所有的技术决策中,总是有一个权衡。是的,容器允许你有更好的隔离,但也给你更多的操作复杂性。如果您想让这种方法为您工作,您需要有一个 CI/CD 管道,这样部署一个任务的新版本就不会成为负担。
尽管不受我们可以用来完成任务的库或语言的限制很酷。这通常是人们所忽略的,但是能够试验和尝试新事物对团队的士气是一个主要的优势。
资源
Python 中的 Apache Kafka:如何与生产者和消费者共享数据流
从头开始编写 Kafka 生产者和消费者的流聊天数据。
照片由努贝尔森·费尔南德斯在 Unsplash 拍摄
在大数据的世界里,可靠的流媒体平台是必不可少的。阿帕奇卡夫卡是一条路要走。
今天的文章将向您展示如何使用 Python 与 Kafka 生产者和消费者合作。你应该通过 Docker 配置 Zookeeper 和 Kafka。如果不是这样,请在继续之前阅读这篇文章或观看这段视频。
不想看书?坐下来看:
本文涵盖以下主题:
- 环境设置
- 用 Python 创建假数据
- 用 Python 写卡夫卡制作人
- 用 Python 编写 Kafka 消费者
- 测试
你可以在 GitHub 上找到源代码。
环境设置
首先打开一个新的终端窗口并连接到 Kafka shell。您应该已经运行了 Zookeeper 和 Kafka 容器,如果不是这样,请启动它们:
docker exec -it kafka /bin/sh
cd /opt/kafka_<version>/bin
ls
以下是您应该看到的内容:
图 1——打开卡夫卡的外壳(图片由作者提供)
接下来,您应该创建一个主题来存储 Python 生成的消息。以下是如何创建一个名为messages
的主题,然后通过列出所有 Kafka 主题来验证它是如何创建的:
图 2——创建一个卡夫卡主题(作者图片)
这就是你在卡夫卡外壳里要做的一切。您现在可以通过在控制台中键入exit
来离开它。下一步是安装 Python 包来使用 Kafka。它叫做kafka-python
,你可以用下面的命令安装它:
python3 -m pip install kafka-python
安装过程应该是这样的:
图 3 —为 Kafka 安装 Python 库(图片由作者提供)
快到了!最后一步是创建一个文件夹来存储 Python 脚本和脚本文件本身。我在桌面上创建了一个kafka-python-code
文件夹,但是你可以把你的放在任何地方:
cd Desktop
mkdir kafka-python-code && cd kafka-python-codetouch data_generator.py
touch producer.py
touch consumer.py
这就是你开始编码所需要的一切!
用 Python 创建假数据
如果你想了解卡夫卡是如何工作的,你需要一种方法来确保恒定的数据流。这就是为什么您将创建一个生成虚假用户消息数据的文件。
文件data_generator.py
的generate_message()
函数将有这个任务。它将选择一个随机的用户 ID 和收件人 ID——只是一个从 1 到 100 的随机数,确保它们不相同——并以 32 个字符的随机字符串形式创建一条消息。
因为我们将发送编码为 JSON 的消息,所以这三个都以 Python 字典的形式返回。
下面是完整的代码:
import random
import string user_ids = list(range(1, 101))
recipient_ids = list(range(1, 101)) def generate_message() -> dict:
random_user_id = random.choice(user_ids) # Copy the recipients array
recipient_ids_copy = recipient_ids.copy() # User can't send message to himself
recipient_ids_copy.remove(random_user_id)
random_recipient_id = random.choice(recipient_ids_copy) # Generate a random message
message = ''.join(random.choice(string.ascii_letters) for i in range(32)) return {
'user_id': random_user_id,
'recipient_id': random_recipient_id,
'message': message
}
这个功能很简单,但是它能很好地满足我们的需求。接下来,我们用 Python 写一个卡夫卡制作人。
用 Python 写卡夫卡制作人
这是有趣的事情开始的地方。现在您将看到如何用kafka-python
库编写一个生产者代码。打开producer.py
文件,你就可以开始了。
去往卡夫卡的消息需要以某种方式序列化。既然我们以 Python 字典的形式获得它们,那么唯一合理的选择就是 JSON。您将编写一个助手serializer()
函数,将它看到的任何东西都转换成 JSON 并编码为utf-8
。
卡夫卡制作人需要知道卡夫卡在哪里运行。如果您在配置阶段没有更改端口,那么您的端口可能在localhost:9092
上。此外,KafkaProducer
类需要知道值将如何被序列化。你知道这两个问题的答案。
现在到了您将生成消息并将它们发送到messages
主题的部分。您将创建一个无限循环,因此消息会一直发送,直到您停止 Python 脚本。在循环中,您将生成消息,打印带有时间戳的消息,并将其发送到 Kafka 主题。
为了避免控制台中的消息数量过多,最好在消息发送后设置一些睡眠时间。Python 将休眠随机的秒数,范围在 1 到 10 秒之间。
这就是卡夫卡制作人的全部。以下是完整的源代码:
import time
import json
import random
from datetime import datetime
from data_generator import generate_message
from kafka import KafkaProducer # Messages will be serialized as JSON
def serializer(message):
return json.dumps(message).encode('utf-8') # Kafka Producer
producer = KafkaProducer(
bootstrap_servers=['localhost:9092'],
value_serializer=serializer
) if __name__ == '__main__':
# Infinite loop - runs until you kill the program
while True:
# Generate a message
dummy_message = generate_message()
# Send it to our 'messages' topic
print(f'Producing message @ {datetime.now()} | Message = {str(dummy_message)}')
producer.send('messages', dummy_message)
# Sleep for a random number of seconds
time_to_sleep = random.randint(1, 11)
time.sleep(time_to_sleep)
*简单吧?*我们仍然需要编写消费者代码,所以我们接下来就这么做吧。
用 Python 写卡夫卡式的消费者
卡夫卡的消费者将更容易编码出来。当消费者启动时,您将获得来自messages
主题的所有消息,并将它们打印出来。当然,您并不局限于打印消息——您可以做任何您想做的事情——但是让我们把事情简单化。
auto_offset_reset
参数将确保最早的消息首先显示。
以下是完整的源代码:
import json
from kafka import KafkaConsumer if __name__ == '__main__':
# Kafka Consumer
consumer = KafkaConsumer(
'messages',
bootstrap_servers='localhost:9092',
auto_offset_reset='earliest'
)
for message in consumer:
print(json.loads(message.value))
这就是你要做的一切!接下来让我们对它们进行测试。
测试
如果可以的话,并排打开两个终端窗口。这在 Mac 上很容易,因为你可以按照你认为合适的方式排列标签。在两个选项卡中打开 Python 脚本所在的文件夹:
图 4 —测试 Python 消费者和生产者(1)(图片由作者提供)
您将希望首先启动消费者,因为您不想错过任何消息。所有旧邮件将首先被打印,您将看到新邮件在生成时被打印出来。使用以下命令启动消费者:
python3 consumer.py
现在,在另一个选项卡中使用以下命令来启动生成器:
python3 producer.py
您将立即看到一条打印出来的消息:
图 5 —测试 Python 消费者和生产者(2)(图片由作者提供)
请记住,生成器在发送下一条消息之前会随机休眠几秒钟。最好让两个窗口都运行一分钟左右,只是为了验证一切都正常工作。
以下是我的机器上的结果:
图片 6 —测试 Python 消费者和生产者(3)(图片由作者提供)
一切都像广告宣传的那样有效!接下来让我们总结一下。
离别赠言
这就是你的第一个基于 Python 的 Apache Kafka 消费者和生产者代码。当然,这是一个虚拟的例子,但是不管您要做什么样的代码更改,原则都是一样的。
如果有些事情对你不起作用,参考视频,因为这样更容易跟进。
对于整个 Apache Kafka 迷你文章/视频系列来说都是如此——我希望它足够容易理解,并且您在这个过程中学到了一些有用的东西。感谢阅读。
喜欢这篇文章吗?成为 中等会员 继续无限制学习。如果你使用下面的链接,我会收到你的一部分会员费,不需要你额外付费。
https://medium.com/@radecicdario/membership
保持联系
原载于 2021 年 9 月 27 日https://betterdatascience.comT22。
Apache Spark 3.1 发布:Kubernetes 上的 Spark 现在已经正式发布
随着 2021 年 3 月发布的 Apache Spark 3.1,Kubernetes 项目上的 Spark 现已正式宣布为生产就绪,并普遍可用。这是自 Spark 2.3(2018 年 2 月)添加对 Spark-on-Kubernetes 的初始支持以来,3 年来快速增长的社区贡献和项目采用的成就。在本文中,我们将回顾 Spark 3.1 的主要特性,特别关注对 Spark-on-Kubernetes 的改进。
相关资源:
- 关于使用 Kubernetes 作为 Spark(而不是 YARN)的资源管理器的介绍,请看在 Kubernetes 上运行 Spark 的 优点&缺点 。
- 有关在 Kubernetes 上成功使用 Spark 的技术指南,请查看 设置、管理&监控 Kubernetes 上的 Spark。
- 关于数据机制的背景知识,请查看 Kubernetes 上的Spark Made Easy:Data Mechanics 如何改进开源版本
Spark-on-Kubernetes 之旅:从 2.3 中的 beta 支持到成为 3.1 中的新标准
随着 2018 年初 Spark 2.3 的发布,Kubernetes 成为了 Spark 的新调度程序(除了 YARN、Mesos 和 Standalone mode 之外),这要归功于 RedHat、Palantir、谷歌、彭博和 Lyft 等少数几家带头开展该项目的大公司。这种最初的支持是实验性的——缺乏特性,并且存在稳定性和性能问题。
从那时起,社区支持蓬勃发展,许多大大小小的公司都被 Kubernetes 的好处所吸引:
- 本地集装箱化。使用 Docker 来打包你的依赖项(和 Spark 本身)——查看我们为 Spark 优化的 Docker 图片。
- 高效的资源共享和更快的应用启动时间。
- 丰富的开源生态系统减少了云提供商和供应商的束缚
这个项目的主要特点是——从 2.4 中的 PySpark & R 支持、客户端模式和卷挂载等基本要求,到动态分配(3.0)和更好的节点关闭处理(3.1)等强大的优化。在过去的 3 年中,总共有超过 500 个补丁(改进和错误修复)被贡献出来,使得 Kubernetes 上的 spark 更加稳定和高效。
Kubernetes 上 Spark 改进的时间线从 2018 年的 Spark 2.3,到 2021 年 3 月最新的 Spark 3.1。图片作者。
因此,Kubernetes 越来越被认为是 2021 年新 Spark 项目的标准资源管理器,我们可以从开源Spark-on-Kubernetes operator项目的受欢迎程度,或者主要供应商采用 Kubernetes 而不是 Hadoop YARN 的声明中看出这一点。
有了 Spark 3.1,Spark-on-Kubernetes 项目现在被认为是普遍可用和生产就绪的。在这个最新版本中,超过 70 个错误修复和性能改进被贡献给了这个项目。现在,让我们深入了解最具影响力的功能,也是我们的客户热切期待的功能。
更好地处理节点关闭——优雅的执行器退役(新的 Spark 3.1 特性)
这个功能( SPARK-20624 )是由 Holden Karau 实现的,目前只适用于 Kubernetes 和单机部署。它被称为“更好地处理节点关闭”,尽管“优雅的执行器退役”是它的另一个好名字。
这个特性使得 Spark 在使用 spot 节点(也就是 GCP 上的可抢占节点)时更加健壮和高效。它确保在 spot 中断发生之前,移动 shuffle 和缓存数据,以便 Spark 应用程序可以在影响最小的情况下继续运行。在此功能之前,当发生定点清除时,随机文件会丢失,因此需要重新计算(通过重新运行可能非常长的任务)。此功能不需要设置外部 shuffle 服务(这需要昂贵的存储节点按需运行,并且与 Kubernetes 兼容)。这个用一张图更好描述。
Spark 的新功能的一个例子,它可以预测当场死亡并优雅地终止执行者,而不会丢失宝贵的数据!图片作者。
这个功能有什么作用?
- 将要离开的执行程序被列入黑名单 Spark 驱动程序不会在它上面安排新的任务。当前在其上运行的 Spark 任务不会被强制中断,但是如果它们失败(由于执行器死亡),这些任务将在另一个执行器上重试(与今天相同),并且它们的失败不会计入最大失败次数(新)。
- 执行程序中的随机文件和缓存数据会迁移到另一个执行程序中。如果没有其他执行程序(例如,我们正在删除那里唯一的执行程序),您可以配置一个对象存储(如 S3)作为后备存储。
- 一旦完成,执行者就死了,Spark 应用程序可以不受影响地继续运行!
这个什么时候生效?
- 当您使用 spot/preemptable 节点时,云提供商(aws、gcp、azure)现在会提前 60-120 秒通知您。Spark 现在可以利用这个时间段来保存我们宝贵的洗牌文件了!当云提供商实例因其他原因关闭时,如 ec2 维护事件,同样的机制也适用。
- 当一个 Kubernetes 节点被清空(例如为了维护)或者 Spark executor pod 被驱逐(例如被一个更高优先级的 pod 抢占)时。
- 当执行程序作为动态分配的一部分被删除时,在缩减期间,因为执行程序空闲。在这种情况下,缓存和随机文件也将被保留。
如何打开它?
- 配置标志。需要开启的 4 个主要 Spark 配置分别是Spark . dissolution . enabled,Spark . storage . dissolution . rddblocks . enabled,Spark . storage . dissolution . shuffle blocks . enabled,Spark . storage . dissolution . enabled。
我建议直接参考源代码来查看其他可用的配置。 - 云提供商警告节点即将离开(例如由于定点清除)的能力需要特定的集成。我们建议查看一下 AWS 、 GCP 和 Azure 的 NodeTerminationHandler 项目。如果你是数据力学的客户,请注意我们正在为你做这项工作。
Kubernetes 上 Spark 的新卷选项
从 Spark 2.4 开始,在 Kubernetes 上使用 Spark 时,可以挂载 3 种类型的卷:
- 一个 emptyDir :共享一个 pod 生命周期的初始空目录。这对于临时存储很有用。这可以由节点的磁盘、SSD 或网络存储提供支持。
- A hostpath :将一个目录从底层节点挂载到您的 pod。
- 静态预创建的 PersistentVolumeClaim 。这是 Kubernetes 对各种类型的持久存储的抽象,比如 AWS EBS 、 Azure Disk ,或者 GCP 的持久磁盘。PersistentVolumeClaim 必须由用户提前创建,并且其生命周期与 pod 无关。
Spark 3.1 支持两个新选项——NFS 和动态创建的 PersistentVolumeClaims。
NFS 是一个可以同时由许多 pod 共享的卷,并且可以预先填充数据。因此,这是一种跨 Spark 应用程序,或者跨给定 Spark 应用程序中的驱动程序和执行器交换数据、代码和配置的方式。Kubernetes 不运行 NFS 服务器,你可以自己运行,或者使用云服务(比如 AWS EFS ,GCP Filestore ,或者 Azure Files )。
一旦您创建了 NFS 共享,现在使用 Spark 3.1 只需使用 Spark 配置就可以很容易地将其安装到您的 Spark 应用程序中:
spark . kubernetes . driver . volumes . NFS . my share . mount . path =/shared
spark . kubernetes . driver . volumes . NFS . my share . mount . readonly = false
spark . kubernetes . driver . volumes . NFS . my share . options . server = NFS . example . com
spark . kubernetes . driver . volumes . NFS . my share . options . path =/storage/shared
NFS(网络文件系统)是在所有 Spark 应用程序之间共享数据的热门选择。它现在可以在 Kubernetes 上运行。图片作者。
第二个新选项是动态 PVC,这是一种更加用户友好的使用持久卷的方式。以前,您必须预先创建 PVC,然后装载它们。如果你使用动态分配,你不知道在你的应用程序执行过程中会产生多少执行者,所以这很难做到。您还必须自己清理未使用的持久卷,或者接受存储资源的浪费。
有了 Spark 3.1,一切都是动态和自动化的。当您提交 Spark 应用程序时(或者当您在动态分配期间请求新的执行器时),Kubernetes 中会动态创建 PersistentVolumeClaims,这将自动提供您所请求的存储类的新 PersistentVolumes(例如 AWS EBS 、 Azure Disk 或 GCP 的持久性磁盘)。删除 pod 后,关联的资源会自动销毁。
其他 Spark 3.1 特性:PySpark UX、阶段级调度、性能提升
除了 Kubernetes go GA 上的 Spark 之外,Spark 3.1 还带来了许多显著的功能。在这里我们将集中讨论几个主要的问题。
PySpark 开发者 UX 在 Spark 3.1 中获得了两大改进:
- PySpark 文档已经接受了完全的重新设计,这使得它更加 pythonic 化和用户友好。看看吧!
- 类型提示支持现在意味着您应该在 IDE 中免费获得代码完成和静态错误检测。
下面是 Apache Spark 3.1 中 PySpark 用户可以使用的 IDE 自动完成功能的示例。来源:https://github.com/zero323/pyspark-stubs
Spark History Server 可以在应用程序完成后呈现 Spark UI,它现在会显示您运行的结构化流查询的统计信息。
阶段级调度( SPARK-27495 )仅适用于启用动态分配时的 YARN 和 Kubernetes 部署。它允许您在代码中控制在一个阶段的粒度上请求的执行器资源的数量和类型。具体来说,您可以配置您的应用程序,在应用程序的第一阶段使用带有 CPU 的执行器(比如说,执行 ETL 和准备数据),然后在应用程序的第二阶段使用 GPU(比如说,训练 ML 模型)。
在性能方面,Spark 3.1 改进了混洗散列连接的性能,并在子表达式消除和 catalyst 优化器中添加了新规则。对于 PySpark 用户来说,内存中的列格式 Apache Arrow 版本 2.0.0 现在与 Spark 捆绑在一起(而不是 1.0.2),这应该会使您的应用程序更快,特别是如果您倾向于在 Spark 和 Pandas 数据帧之间转换数据。好消息是,您将免费从这些性能改进中受益,无需任何代码或配置更改。
结论
Spark 3.1 是 Apache Spark 的一个激动人心的版本,庆祝了社区多年来对 Spark-on-Kubernetes 集成的支持,标志着它的支持已经普遍可用并可以生产。这对于我们 Data Mechanics 来说并不意外,因为我们一直在帮助客户迁移到 Kubernetes,并在开发人员体验和性能/成本降低方面不断取得巨大成果。
除了即将正式上市的 Kubernetes 上的 Spark 之外,优雅的退役功能还实现了将计算资源从存储中完全分离出来的愿景,并通过 Spark 使经济高效的 spot 实例的使用更加稳定。我们对该功能的初步测试非常有希望——我们将很快发布一个我们的大客户使用该功能的故事,敬请关注!
本文原 发表于数据力学博客 。
Apache Spark 3.2 版本—Spark-on-Kubernetes 的新特性
主要功能解释:PersistentVolumeClaim 重用(k8s 专用)、考拉、Hadoop 3.3、RocksDB 等等!
Apache Spark 3.2 为每个人带来了性能和稳定性的改进,以及一个令人兴奋的新特性,使 Spark-on-Kubernetes 在 spot 节点上表现出色!来源: Unsplash 。
Apache Spark 3.2 于 2021 年 10 月发布(参见发布说明),现在面向数据机制客户,以及任何希望在 Kubernetes 上运行 Spark(或简单地在 Docker 上运行 Spark)的人,因为我们更新了 Spark 的优化 Docker 映像的 DockerHub 库。
在本文中,我们将带您浏览 Apache Spark 的新特性,这些新特性让我们兴奋不已。我们将首先看一下有利于整个 Spark 社区的总体改进,然后关注一个针对 Spark-on-Kubernetes 用户的激动人心的新开发。
主要特性—总体改进
Hadoop 3.3.1 —性能改进
Spark 3.2 现在默认使用 Hadoop 3.3.1 (而不是之前的 Hadoop 3.2.0)。你可能认为这不适用于你(特别是如果你在 Kubernetes 上运行 Spark),但实际上即使你不在 Hadoop 基础设施上运行,Spark 也使用 Hadoop 库。特别是,Spark 在从对象存储中读取数据时会使用 Hadoop 库(比如 S3 ),所以这一点非常重要。
Hadoop 3.3.1 版本为 S3A 连接器( HADOOP-17400 )带来了显著的性能提升。这些改进调优了 s3 清单的性能,并删除了许多对 S3 API 的不必要调用,因此降低了被抑制的风险,并且总体上提高了 Spark 从 S3 读取时的性能。
另一个值得注意的变化是“ Magic S3 提交器”现在更易于使用,甚至被推荐为默认设置,因为我们现在可以受益于 S3 自 2020 年 12 月以来在全球范围内支持强写后读一致性的事实。Spark 3.2 和 Hadoop 3.3.1 版本修复了一些错误,并显著提高了提交器的性能。
你现在可以通过打开单个 Spark 配置标志(而不是多个)来启用 magic committer,其中<桶>是你写入数据的 S3 桶:" Spark . Hadoop . fs . s3a . bucket .<桶>. committer . magic . enabled “:” true "。
If,你不知道什么是 S3 提交器,我们推荐你阅读这个文档,它有点过时,但是对 S3 提交问题做了很好的介绍。还要注意的是,Hadoop 维护者正在开发一个类似的提交器,为 Google Cloud 和 Azure 的对象存储进行了优化。
自适应查询执行—性能改进
Spark 开发团队一直在寻找提高 Spark SQL 查询优化器效率的方法。查询优化器负责选择适当的连接方法、任务执行顺序,并根据从底层数据中获得的各种统计信息决定连接顺序策略。
自适应查询执行是这些优化技术之一,最初发布于 Spark 3.0。在 Spark 3.2 中,默认情况下启用自适应查询执行(您不再需要配置标志来启用它),并且与其他查询优化技术(如动态分区修剪)兼容,使其更加强大。
AQE 允许查询优化器在某些阶段完成时收集数据,根据从查询执行中收集的新统计信息重新构建查询计划,并在应用程序执行过程中对查询计划应用这些更改。
自适应查询执行在 Spark SQL 查询运行时对其进行优化。
AQE 通过应用以下策略来优化查询执行:
- 动态合并混洗分区
- 动态切换连接策略(动态选择使用广播连接或混合散列连接,而不是较慢的排序-合并连接)
- 动态优化偏斜连接
不同的配置标志让你调整这些优化的行为,更多细节请参考文档。这些配置标志是在数据力学平台上为您自动设置的。
数据源 V2 聚合下推-性能改进
当 Spark 从任何存储中读取数据时(对象存储中的 parquet 文件、数据仓库、HDFS、SQL 数据库……),它使用一个实现特定 API 的库。随着 2018 年 Spark 2.3 的发布,发布了新的 API(名为 DataSource V2),主要的常用数据连接器(阅读器和写入器)都移植到了它上面。基本 API (V1)只允许 Spark 以分布式方式读/写数据。新的 API (V2)在数据源层实现了很多优化,比如通过在数据源“向下”推送过滤器来减少数据读取量。
有了 Spark 3.2,您现在可以从包含聚合过滤器或选择聚合列的查询上的谓词下推中受益。举一个具体而简单的例子,让 Spark 计算一个拼花文件中的行数现在会快得多,因为 Spark 可以直接从文件的元数据中读取它,而不是扫描它(参见 SPARK-35511 )。
支持的聚合函数包括计数、求和、最小值、最大值、平均值。通过聚合下推,Spark 可以将聚合谓词应用于数据源,从而减少通过网络读取和混合的文件数量。如果查询中包含多个聚合,并且数据源支持这些聚合,Spark 将下推所有聚合。
请注意,如果您选择一个聚合和一个维度列,该聚合将不会被下推到数据源。还要注意的是,当使用 parquet 或 Delta 作为数据源时,这种优化效果最明显。
考拉——熊猫用户的 PySpark 改进
Koalas 是流行的 Pandas 库的 Spark 实现,作为 PySpark 的首选转换库,它越来越受欢迎。在 Spark 3.2 中,考拉现在将默认与 Spark 捆绑在一起,它不需要作为附加库安装。我们的目标是让 Spark 更容易被习惯在单台机器上运行纯 python 代码的人访问,事实上,现在使用 PySpark 运行相同的代码比使用纯 Pandas 要快得多,即使您不运行 Spark 的分布式功能(这要感谢 Spark SQL 中内置的许多优化)。
Api 变更
以前,如果您想要在熊猫、考拉或 Spark 功能之间切换,您需要应用转换函数来获得适当的 DataFrame 对象,从而允许您利用特定的转换。随着 Spark 3.2 的发布,Pandas 现在可以在 Spark 上本地运行,并且包含在 PySpark API 中。
星火 3.2 之前进口熊猫。图片作者。
上面的单元格描述了使用 Pandas 函数、创建 Pandas 数据帧和转换为 Spark 数据帧的工作流程。这种操作效率特别低,因为在开始 Spark 操作之前,您会将整个“test.csv”收集到您的驱动程序中,从而放弃了 Spark 的一个关键优势,即分布式处理。此外,代码本身变得乏味,因为我们需要在库之间切换,以便利用我们的应用程序所需的转换和功能。
从 Spark 3.2+导入熊猫。图片作者。
使用 Spark 3.2,您可以直接从 PySpark API 导入 read_csv 函数,并接收 Spark 数据帧,这样就不需要在两个库之间来回转换了。此外,read_csv 函数现在是使用 Spark 作为后端实现的,这意味着在内存中读取和处理 csv 时,您将受益于并行化。这种实现不仅允许开发人员生成更干净的 PySpark 应用程序,还可以消除混淆,即哪些操作只在 Spark 驱动程序上执行,哪些操作分布在执行器上。
形象化
标准的 Python Pandas 实现打包了 matplotlib 作为默认的绘图库。在 3.2 版本中,PySpark Pandas 默认使用。Plotly 提供了许多增强功能,例如对交互式放大和缩小图形的本机支持,以及使用 Spark 重新计算绘图视图。如果选择切换回 matplotlib,可以在 Spark 配置中指定 PySpark 绘图库。
RocksDB —火花流改进
标准的 Spark 流配置通过在内存中存储持久值来支持有状态操作,如流聚合或连接。对于一些具有高基数的流工作负载,在内存中存储状态可能不够,导致应用程序将数据溢出到磁盘并影响性能。
Spark 现在支持 RocksDB ,这是一个用于高性能状态管理的持久性键值存储。与传统的内存解决方案相比,RocksDB 在查找性能和延迟方面都有显著提升。要使用 RocksDB 运行 Spark 应用程序,请添加以下配置设置:
" spark . SQL . streaming . statestore . provider class “:” org . Apache . spark . SQL . execution . streaming . state . rocksdbstatestoreprovider "
Kubernetes 上的火花改进
Spark UI 中的执行器失败原因
新的执行程序失败原因列位于右侧。图片作者。
Spark UI 现在在它的执行者页面上增加了一个新的列,给你一个 Spark 执行者死亡的原因。这一改进适用于所有 Spark 用户,而不仅仅是 Spark-on-Kubernetes。对于 Spark-on-Kubernetes 用户来说,一个阻止错误代码传播的 bug 已经修复,并且提供了一些额外的见解,例如将 Docker 退出代码翻译成人类可以理解的东西。
火花 PMC·霍尔登·卡劳添加了新的有趣的错误信息(来源: Github
执行程序重启时 Kubernetes 持久性卷声明重用
在 3.1 版本中,Spark 引入了在 Kubernetes 工作负载上动态生成、装载和删除持久卷声明 (PVCs)的能力,这些工作负载基本上是装载到 Spark pods 中的卷,由 Kubernetes 管理,并由物理云卷(如 AWS 上的 EBS、GCP 上的持久磁盘或 Azure 磁盘)提供支持。如果你想在我们的平台上做这件事,请看我们的文档。
如果由于定点清除或失败(例如 JVM 耗尽内存)而丢失了一个执行器,那么持久化卷将在执行器 pod 终止的同时丢失,从而迫使 Spark 应用程序重新计算丢失的工作(洗牌文件)。
Spark 3.2 增加了 PVC 重用和洗牌恢复来处理这种确切的场景( SPARK-35593 )。如果执行程序或节点在运行时丢失,Spark 将保留最初与丢失的执行程序相关联的持久卷声明,从 Kubernetes 请求一个新的 pod,并将现有的卷附加到它上面,以便宝贵的 shuffle 文件不会丢失,Spark 可以恢复已经完成的工作,而不是重新计算它。
演示 Exec-2 如何恢复存储在与 Exec-1 关联的 PVC 中的随机文件的动画 GIF。图片作者。
该特性将极大地提高 Spark-on-Kubernetes 在处理偶发执行器故障时的健壮性和效率,并有助于使用现场/可抢占节点,与传统的按需节点相比,可节省 60%以上的成本。这是 Spark-on-k8s 用户的一大胜利!
要启用动态 PVC 重用,请将以下设置添加到应用程序的 Spark 配置中。请注意,前两个标志是 Spark 3.2 中的新标志,其他标志来自 Spark 3.1 中的动态 PVC 特性
结论
Apache Spark 3.2 为 Spark 在所有用例中带来了显著的性能提升——从 Spark SQL 用户(AQE),到熊猫开发者(考拉),以及流用户(RocksDB)。这也是 Spark-on-Kubernetes 项目(从 Spark 3.1 开始正式发布)增加稳定性和成熟度的另一个版本,因为 PVC 重用以一种健壮的方式解决了在应用程序运行时丢失 Spark 执行器的问题。
当您在我们的平台上将工作负载升级到 Spark 3.2 时,我们期待展示这些影响。如果您不是客户,但对迁移到 Spark-on-Kubernetes 感兴趣,请向我们预订演示!
Apache Spark Monitoring:如何使用 Spark API 和开源库来获得应用程序更好的数据可观察性
Arie Wubben 在 Unsplash 上拍摄的照片
了解如何使用监听器 API 和数据质量库为 Apache Spark 获得不同级别的数据可观察性。
Spark 对于现代数据堆栈至关重要。因此,对于您的 Spark 环境来说,拥有正确的可观察性水平是极其重要的。监控 Spark 有很多选项,包括为 Spark 和 Spark SQL 指标提供预配置仪表板的 SaaS 程序。如果这还不够呢?
典型的 Spark 应用程序设置,无论是自托管还是托管解决方案,都包括一些用于集群健康监控的操作仪表板。尽管这些控制面板非常有用,但它们只为我们带来了基础架构概述,而不是与数据相关的实际指标。是的,当 CPU 使用率增加或集群内存不足时,我们可以假设应用程序可能有问题,但当源改变了模式或来自另一个部门的数据被破坏时,这没有帮助。工程师面临的大多数问题都是由数据引起的,而不是由底层基础设施引起的,因此他们不得不花费大量时间重现问题或者像侦探一样摆弄文件和桶。这正是实际应用程序监控可以提供帮助的地方。
每种情况都需要不同级别的可见性,数据工程师需要具备比执行指标更深入的能力。否则,您可能会花费大量时间在 Spark 中调试数据质量问题。
在本指南中,您将了解如何为 Spark 获得高级和低级的数据可观察性。对于高层,您将使用 Spark 的内部系统,如监听器 API 和查询执行监听器。对于底层,您将学习如何使用库来跟踪数据质量度量。
学会这两种方法后,你可以选择哪一种最适合你要解决的问题。
监视 Apache Spark 的低级方法
火花监听器
这是一种非常古老且可靠的获取指标的方法。实际上, Spark UI 利用完全相同的机制来可视化指标。Spark 监听器 API 允许开发人员跟踪 Spark 在应用程序执行期间发出的事件。这些事件通常是应用程序开始/结束、作业开始/结束、阶段开始/结束等。你可以在 Spark JavaDoc 中找到完整的列表。它很容易配置,也很容易使用 Spark 监听器来获取指标。在执行每个操作之后,Spark 将调用 Spark Listener 并将一些元数据信息传递给它的方法。这将包括像执行时间,记录读/写,字节读/写和其他。
这种非常基本和低级的数据质量监控将检查记录的数量和大小。假设您有一些每天都在运行的作业,并对传入的数据集执行一些转换/分析。您可以编写一个侦听器来检查从输入中读取了多少记录,并将其与前一天的结果进行比较。当差异显著时,我们可以假设数据源可能有问题。
然而,这种方法需要编写内部监控解决方案。度量值应该存储在某个地方,应该配置警报机制。当应用程序代码将改变时,所有的度量键也将改变,人们应该正确地处理它。
然而,即使是一个简单的 Spark 监听器也可以对您的数据提供一些见解。
这里有一个这样的火花监听器的例子:
您可以通过几种方式将 Spark 监听器添加到您的应用程序中:
以编程方式添加它:
或者通过 spark-submit/spark cluster 驱动程序选项传递它:
Spark 查询执行监听器
这是另一种开箱即用的火花监测机制。查询执行监听器允许开发人员订阅查询完成事件,而不是关注非常低级的指标。它提供了关于执行的查询的更高层次的元数据,如逻辑和物理计划,以及执行度量。
您可以获得像查询读/写的记录这样的指标,但是这次是针对整个查询而不是特定的任务/作业/阶段进行聚合的。
还可以从计划中提取非常有用的信息,如数据位置和模式。您可以提取和存储模式以及数据帧维度,并将其与之前的运行进行比较,在出现问题时触发警报。
然而,从计划中提取数据可能很复杂,因为您被迫使用低级别的 Spark API。
此外,实施指标存储和警报机制的所有运营负担仍然存在。你从 Spark 中得到的只是元数据。开发人员有责任利用它。
下面是一个简单的查询执行监听器示例,它打印计划和度量:
查询执行侦听器可以通过编程方式或配置方式添加:
通过 spark-提交:
实现低级别的监控可能是非常繁重的工作,然而,“系统”监控方式有一个巨大的好处:它不会引入计算开销。因为元数据是由 Spark 内部发出和记录的,所以它不会对查询执行时间造成任何损失。
使用监听器进行监控可以让您完全避免接触应用程序代码!当您想要跟踪现有和遗留应用程序上的数据,但没有预算进行更改时,这将带来巨大的好处。只需编写一个侦听器,通过 spark 配置传递它,并获得您的数据的图片。
监视 Apache Spark 的高级方法
人工数据质量检查
通过手动验证,您可以大大增强对传入数据的信心。假设我们期望输入数据源中有一定数量的记录,并且该数量通常不应该低于 x。我们可以写一些非常简单的东西,比如:
这里的可能性是无限的。我们可以比较计数、非空值计数、推断模式等。
使用数据质量库
由于许多质量检查或多或少都是琐碎的,比如确保数据帧具有正确的形状和内容,社区为此类检查开发了方便的库。其中一个图书馆是 Deequ 。它为大多数情况提供了丰富的领域特定语言(DSL)。看看这个。此外,它还具有一些高级功能,如分析列的能力——计算最小值/最大值/平均值/百分位数、计算直方图、检测异常等等。
考虑 Deequ 文档中的以下示例:
你可以看到我们有一大堆的支票包装在一个漂亮的随时可用的 DSL 中。
更重要的是,Deequ 提供了存储检查结果和自动运行与以前运行的比较的能力。这可以通过利用度量库来完成。用户可以编写自己的实现,并将 Deequ 无缝集成到现有的监控基础设施中。
虽然高级别的应用程序质量检查比低级别的方法灵活得多,但是它们也有一个很大的缺点:性能损失。由于每个计算都会产生火花操作,因此在某些情况下,开销会非常大,尤其是在大型数据集上。每个“计数”和“位置”都可以导致全面扫描。Spark 内部将尽力优化执行计划,但您应该考虑这些影响,并确保数据分析不会损害您的性能。
结论
我们已经回顾了几种监测 Spark 应用程序数据质量的方法。低级方法利用 Spark 事件监听器 API,并提供对低级指标(如记录读/写、逻辑/物理计划)的访问,可用于构建趋势,确保数据管道产生正确的结果,并在不修改任何代码的情况下获得现有应用的概览。像手工检查数据或使用数据质量库这样的高级方法要方便得多,但是也有一些缺点,比如性能损失。
正如在任何实际情况中一样,根据您的应用程序类型,这两种方法总会有折衷和更好的方案。明智地使用它。
在 Databand ,我们利用这两种方式提供一套全面的选项来跟踪 Spark 应用。虽然在我们的核心中,我们使用 Spark 监听器来构建指标趋势和数据谱系,但我们也为 Deequ 提供了方便的指标存储,以及跟踪单个手动计算的指标的能力。
Apache Spark —多部分系列:Spark 架构
理解大数据
Spark 架构是最初学习 Spark 时最难掌握的元素之一。我认为其中一个主要原因是有大量的信息,但没有什么能让我们深入了解 Spark 生态系统的方方面面。这很可能是因为,它很复杂!有这么多奇妙的资源,但并不是所有的都是直观的或容易理解的。
我希望这个系列的这一部分能够帮助那些对这个主题知之甚少的人从基础开始理解 Spark Architecture 是如何构建的。我们还将研究如何为我们的 Spark 系统提供工作,以及系统如何以最有效的方式消耗和完成这些工作。
正如我所承诺的,这部分会更重一点。所以系好安全带,这将会很有趣!
物理火花等级:
为了理解 Spark 程序是如何工作的,我们需要理解 Spark 系统是如何一砖一瓦地构建起来的(参见我在那里所做的)。有许多不同的方法来建立一个 Spark 系统,但是对于这个系列的这一部分,我们将讨论一个最流行的方法来建立它。
一般来说,Spark 系统是由许多独立的机器组成的,它们为了一个共同的目标一起工作,这被称为集群或分布式系统。要让这样的系统工作,我们需要一台机器来整体管理集群。该机器通常被标记为驱动节点。
驱动节点
Spark 驱动程序用于协调整个 Spark 集群,这意味着它将管理分布在集群中的工作,以及在整个集群生命周期中哪些机器是可用的。
逐步驱动程序节点(由 Luke Thorp 创建)
- 驱动程序节点像任何其他机器一样,它有 CPU、内存、磁盘和缓存等硬件,但是,这些硬件组件用于托管 Spark 程序和管理更广泛的集群。驱动因素是用户之间的链接,以及完成提交给群集的任何工作所需的物理计算。
- 由于 Spark 是用 Scala 编写的,所以要记住集群中的任何机器都需要运行一个 JVM (Java 虚拟机),这样 Spark 才能与主机上的硬件一起工作。
- Spark 程序在这个 JVM 内部运行,用于创建 SparkContext,这是用户访问 Spark 集群的访问点。
驱动程序包含 DAG(有向无环图)调度器、任务调度器、后端调度器和块管理器。这些驱动程序组件负责将用户代码翻译成在集群上执行的 Spark 作业。隐藏在驱动程序节点中的是集群管理器,它负责获取 Spark 集群上的资源并将它们分配给 Spark 作业。这些资源以工作节点的形式出现。
工作节点
工作者节点形成集群的*【分布式】*部分。这些节点有各种形状和大小,加入集群时不一定总是相同的,它们的大小和性能可以不同。但是,当进一步调查性能瓶颈时,拥有性能相同的工作节点可能会有好处。所以,这是需要记住的事情。
Worker Node Step by Step(由 Luke Thorp 创建)
- 工作节点通常是独立的机器,其硬件与任何其他节点一样。例如 CPU、存储器、磁盘和高速缓存。
- 与驱动程序一样,为了让 Spark 在 worker 节点上运行,我们需要确保系统安装了兼容版本的 Java,以便代码能够以正确且有意义的方式进行解释。
- 当我们在工作节点上安装 Spark 时,我们的驱动节点将能够利用其集群管理功能来映射工作节点上可用的硬件。
集群管理器将跟踪*“插槽”*的数量,这实际上是设备上可用内核的数量。这些插槽被分类为可用的计算卡盘,并且可以由驱动程序节点向它们提供要完成的任务。有一定数量的可用内存被分成两部分,存储内存和工作内存。默认比例是 50:50,但是可以在 Spark 配置中更改。
每个工作人员还会附带一些磁盘。但是我知道你要说什么,Spark 工作在内存,不是磁盘!然而,Spark 仍然需要磁盘来分配 shuffle 分区(将在本文后面进一步讨论),它还需要空间来持久存储到磁盘和溢出到磁盘。 - 现在我们有了驱动节点和工作者节点是如何构造的细节,我们需要知道它们是如何交互和处理工作的。
为此,我们将创建一个极简版本的图表来显示一个 worker 节点及其可用的工作插槽。
最小化物理结构
既然我们知道了驱动程序和工作程序是如何独立构造的,我们就可以研究它们在集群环境中是如何内在联系的。如上所述,我们将简化 worker 节点的视图,以便它只显示对下一节重要的元素。(他们还有内存和磁盘!)
查看驱动程序和工作节点,我们可以看到驱动程序节点是集群中节点之间通信的中心。
驱动节点和工作节点架构(由 Luke Thorp 创建)
工作节点能够相互通信和传递数据,但是对于工作和任务,驱动节点只负责向工作节点提供要完成的工作。
在上面的例子中,我们有一个包含一个驱动程序和三个工作节点的集群。每个节点都有四个可用的计算插槽,因此,该集群将能够同时完成十二项任务。值得一提的是,Sparks new photon engine 是一款 Spark 兼容的矢量查询引擎,旨在满足和利用最新的 CPU 架构。这支持更快的数据并行处理,甚至在任务执行期间读取数据时实时提高性能。
Spark 运行时架构
Spark 运行时架构正是它在 tin 上所说的,即代码运行时集群发生了什么。“代码正在运行”可能是错误的阶段。Spark 既有热切的评价,也有慵懒的评价。星火行动渴望;然而,转换天生懒惰。
如上所述,转换是懒惰的,这实质上意味着当我们对数据调用一些操作时,它不会立即执行。Spark 维护被调用操作的记录。这些操作可以包括连接和过滤。
动作是急切的,这意味着当一个动作被调用时,该行代码一运行,集群就开始计算预期的结果。当操作运行时,它们会产生一个非 RDD(弹性分布式数据集)组件。
转换类型:
有两种类型的转换是理解 Spark 如何以分布式方式管理数据和计算的关键,它们是窄和宽。转换从彼此创建 rdd。
狭窄和宽阔的变换(由卢克·索普创造)
- 由一个输入分区定义的窄转换将产生一个输出分区。这方面的一个例子是过滤器:因为我们可以有一个数据帧,我们可以将其过滤成一个更小的数据集,而不需要了解任何其他工作节点上保存的任何数据。
- 大范围转换(混洗)是由工作节点需要通过网络传输(混洗)数据以完成所需任务这一事实定义的。这方面的一个例子是连接:因为我们可能需要从集群中收集数据,以完全正确地完成两个数据集的连接。
扩展的广泛转换(由卢克·索普创建)
宽转换可以采取多种形式,并且 n 个输入分区可能不总是产生 n 个输出分区。如上所述,连接被归类为宽转换。所以在上面的例子中,我们有两个 RDD,它们有不同数量的分区连接在一起形成一个新的 RDD。
运行时间
当用户通过他们喜欢的方法向驱动程序提交代码时,驱动程序隐式地将包含转换(过滤器、连接、分组、联合等)和动作(计数、写入等)的代码转换成未解析的逻辑计划。
在提交阶段,驱动程序参考逻辑计划目录以确保所有代码符合所需的参数,在这完成之后,未解决的逻辑计划被转换成逻辑计划。流程现在执行优化,比如计划转换和行动,这个优化器更好地被称为 catalyst 优化器。
Spark SQL 的核心是 Catalyst optimizer,它以一种新颖的方式利用高级编程语言功能(例如 Scala 的模式匹配和准引号)来构建可扩展的查询优化器。Catalyst 基于 Scala 中的函数式编程结构,其设计有两个主要目的:
-轻松将新的优化技术和特性添加到 Spark SQL 中
-使外部开发人员能够扩展优化器(例如,添加数据源特定规则、支持新数据类型等)。)
Apache Spark Catalyst 优化器:data bricks(【https://databricks.com/glossary/catalyst-optimizer】T2
在本系列的下一部分,我将深入研究 catalyst optimiser,所以不要太担心这一部分。
一旦优化完成,逻辑计划被转换成多个物理执行计划,这些计划被汇集到成本模型中,分析每个计划并应用成本值来运行每个执行计划。完成成本最低的计划将被选为最终产出。此选定的执行计划将包含具有多个阶段的作业。
物理执行计划(由卢克·索普创建)
阶段有小的物理任务,这些任务被打包发送到 Spark 集群。在分配这些任务之前,驱动程序与集群管理器进行对话,以协商资源。
集群任务分配(由 Luke Thorp 创建)
一旦完成这项工作并分配了资源,任务就被分配给有空闲时间的工作节点(执行者),驱动程序监控进度。这是以尽可能最有效的方式完成的,同时牢记当前可用的资源和集群结构。在上面的例子中很简单,我们有三个任务分配给三个工作节点。任务可以以上述方式分配,即每个工作节点一个任务,或者它们可以被发送到一个节点,并且由于 Spark 的并行特性而仍然异步运行。但是,当我们的任务多于可用的计算槽时,或者当我们的任务数与集群中的节点数相比不可整除时,复杂性就会增加。幸运的是,Spark 自己处理这个问题,并管理任务分配。
值得一提的是,尽管 Spark 可以高效地分配任务,但它并不总是能够解释数据,这意味着我们可能会遇到数据偏差,这会导致任务持续时间的巨大时间差。然而,这完全取决于底层数据湖的结构。如果它是使用 Delta Engine (Photon)构建的,我们可以利用查询执行的实时改进,以及鼓励更好的混排大小和最佳连接类型的改进。
Spark UI(用户界面)允许用户交互整个工作负载流程,这意味着用户可以深入每个部分。例如,有针对作业和阶段的选项卡。通过进入一个阶段,用户可以查看与特定任务相关的指标,这种方法可用于了解为什么特定作业可能比预期需要更长时间才能完成。
云与本地
我相信您已经猜到了,Spark 集群通常构建在云环境中,使用云服务提供商提供的流行服务。这是由于这些服务的可扩展性、可靠性和成本效益。然而,您可能没有意识到,如果您想使用 Spark 配置,可以在本地机器上运行 Spark。
到目前为止,最简单的修补方法是注册 Databricks 的社区版。这允许您创建一个小型但功能相当强大的单个工作节点集群,能够运行所有的数据块代码示例和笔记本。如果你还没有做,那就去做吧!有一些限制,群集在两个小时的空闲时间后会超时,但是免费计算就是免费计算!
最后
星火架构很难。理解物理元素以及核心运行时代码如何转化为正在转换的数据并在集群中移动需要时间。当我们完成这个系列时,我们将继续参考这一部分,以确保我们都将基本原理锁定在我们的灰质中。
在本系列中,我将探讨我们能够更深入 Spark 的方法,以便我们能够理解 JVM 结构的基本框架,以及优化器如何对我们未解决的逻辑计划起作用。
值得注意的是,在接下来的部分中,我们将构建一个 Spark 集群,使用一个 Raspberry Pi 作为驱动节点,使用多个 Raspberry Pi Zero 作为工作节点。我只需要等待项目被交付… Covid 交付时间很慢!
如果我错过了本节的任何内容,或者如果还有什么不清楚的地方,请让我知道,这样我就可以改进这个系列,更不用说我的 Spark 知识了!你可以在 LinkedIn 上找到我:
系列部分:
Windows 上的 Apache Spark:Docker 方法
如何用 Docker for Windows 以最少的工作量建立一个 Apache Spark 开发环境
卡斯帕·卡米尔·鲁宾在 Unsplash 上的照片
最近,我被分配到一个项目中,在这个项目中,整个客户数据库都在 Apache Spark / Hadoop 中。作为我所有项目的标准,我首先在公司笔记本电脑上准备开发环境,这是 Windows 作为标准操作系统提供的。正如许多人已经知道的,在 Windows 笔记本电脑上准备开发环境有时会很痛苦,如果笔记本电脑是公司的,那就更痛苦了(由于系统管理员、公司 VPN 等施加的限制)。).
为 Apache Spark / Hadoop 创建开发环境也是如此。在 Windows 上安装 Spark 极其复杂。需要安装几个依赖项(Java SDK,Python,Winutils,Log4j),需要配置服务,需要正确设置环境变量。鉴于此,我决定将 Docker 作为我所有开发环境的首选。
现在,Docker 就是我的“一环 / 一工具”(参考《魔戒》):
“在魔多的土地(窗户)阴影所在的地方。一枚戒指统治他们所有人,一枚戒指寻找他们,一枚戒指带来他们所有人,并在黑暗中束缚他们;在魔多的阴影之地"(托尔金
夏尔——照片由格雷戈&路易斯·努内斯在 Unsplash 上拍摄
如果 Docker 不是你的选择,有几篇文章可以解释这个问题
- [在 Windows 10 上安装 Apache PySpark](http://Installing Apache PySpark on Windows 10)
- Windows 上的 Apache Spark 安装
- Windows 上的 PySpark 入门
为什么是 Docker?
- Windows 上不需要安装任何库或应用,只需要 Docker。每周安装软件和库时,无需请求技术支持人员的许可。(他们会爱你的,相信我)
- Windows 将始终以最大潜力运行(不会有无数服务在登录时启动)
- 拥有不同的项目环境,包括软件版本。例如:一个项目可以使用 Apache Spark 2 和 Scala,另一个项目可以使用 Apache Spark 3 和 pyspark,不会有任何冲突。
- 社区做出来的现成形象有几个(postgres,spark,jupyters 等。),使得开发设置更快。
这些只是 Docker 的一些优势,还有其他的,你可以在 Docker 官方页面 上了解更多。
说了这么多,让我们言归正传,设置我们的 Apache Spark 环境。
为 Windows 安装 Docker
你可以按照 开始指南 下载 Docker for Windows 并按照说明在你的机器上安装 Docker。如果你的 Windows 是家庭版,你可以按照 的说明在 Windows 上安装 Docker 桌面家庭版。
当安装完成后,你可以重新启动你的机器(记得保存这篇文章在收藏夹,以备份从重启)。
如果您在此时或稍后运行任何错误,请查看微软故障排除指南https://docs.microsoft.com/en-us/visualstudio/containers/troubleshooting-docker-errors?view=vs-2019。
你可以从开始菜单启动 Docker,过一会儿你会在系统托盘上看到这个图标:
鲸鱼码头图标
你可以右击图标,选择仪表盘**。在仪表板上,您可以点击配置按钮(右上方的发动机图标)。您将看到以下屏幕:**
Docker 仪表板(图片由作者提供)
我喜欢做的一件事是取消选择选项:
- 登录后启动 docker 桌面。
这样 docker 就不会从 windows 启动,我可以只在需要的时候通过开始菜单启动它。但是这是个人的选择。
检查对接器安装
首先,我们需要确保我们的 docker 安装工作正常。打开一个 Powershell(或 WSL 终端),我强烈推荐令人惊叹的 Windows 终端 ,这是一个 Windows ( 类 Unix)终端,它有很多帮助我们开发人员的功能(标签、自动完成、主题和其他很酷的功能),并键入以下内容:
~$ docker run hello-world
如果你看到这样的东西:
Docker hello-world(图片由作者提供)
你的 docker 安装是 ok 。
Jupyter 和 Apache Spark
正如我前面所说,docker 最酷的特性之一依赖于社区图片。几乎所有的需求都有许多预制的图像可供下载,只需很少或不需要配置就可以使用。花点时间探索一下 Docker Hub ,自己看吧。
Jupyter 开发人员一直在做一项惊人的工作,积极地为数据科学家和研究人员维护一些图像,项目页面可以在这里找到https://jupyter-docker-stacks.readthedocs.io/en/latest/index.html。一些图像是:****
- jupyter/r-notebook 包括来自 R 生态系统的流行软件包。
- jupyter/scipy-notebook 包括来自科学 Python 生态系统的流行包。
- jupyter/tensor flow-notebook包括流行的 Python 深度学习库。
- jupyter/pyspark-notebook 包含对 Apache Spark 的 Python 支持。
- jupyter/all-spark-notebook 包括 Python、R 和 Scala 对 Apache Spark 的支持。
和许多其他人。
对于我们的 Apache Spark 环境,我们选择了jupyter/pyspark-notebook,因为我们不需要 R 和 Scala 支持。
要创建新容器,您可以转到终端并键入以下内容:
****~$ docker run -p 8888:8888 -e JUPYTER_ENABLE_LAB=yes --name pyspark jupyter/pyspark-notebook****
如果本地主机上还没有jupyter/py spark-notebook映像,这个命令会从 Docker Hub 中提取它。
然后,它启动一个运行 Jupyter 笔记本服务器的 name= pyspark 容器,并在主机端口 8888 上公开服务器。
您可以指示启动脚本在启动笔记本服务器之前定制容器环境。您可以通过向 docker run 命令传递参数(-e 标志)来实现这一点。所有可用变量的列表可在 docker-stacks docs 中找到。
服务器日志出现在终端中,并包含笔记本服务器的 URL。您可以导航到该 URL,创建一个新的 python 笔记本并粘贴以下代码:
瞧啊。我们用最少的努力创造了我们的 Apache Spark 环境。您可以打开一个终端,使用 conda 或 pip 安装软件包,并按照您的意愿管理您的软件包和依赖项。完成后,您可以按下 ctrl+C 并停止容器。
数据持久性
如果你想启动你的容器并保存你的数据,你不能再次运行" docker run "命令,这将创建一个新的默认容器,那么我们需要做什么呢?
您可以在终端中键入:
****~$ docker ps -a****
Docker 容器列表(图片由作者提供)
这将列出所有可用的容器,要启动之前创建的容器,请键入:
****~$ docker start -a pyspark****
其中 -a 是一个标志,告诉 docker 将控制台输出绑定到终端,pyspark 是容器的名称。要了解更多关于 docker start 选项的信息,您可以访问Docker docs。
结论
在本文中,我们可以看到 docker 如何加快开发生命周期,并帮助我们减轻使用 Windows 作为主要开发操作系统的一些缺点。微软在 WSL、docker 和其他开发人员和工程师工具方面做得很好,甚至通过 Docker 和 WSL 支持 GPU 处理。未来大有可为。
感谢您阅读这篇文章,希望这篇小指南能够帮助您,并给任何决定使用 Docker for Windows 作为您的“ One ring ”的人提供宝贵的见解。
请随时在评论区提问或提供反馈。
Apache Spark 性能提升
关于 Pyspark 性能技巧的综合指南
Apache Spark 是一个通用的分布式数据处理平台,专门用于大数据应用。它成为处理大数据的事实标准。根据其分布式和内存中的工作原理,默认情况下它应该执行得很快。然而,在现实生活中并非总是如此。这篇文章是我上一篇文章的后续,我的上一篇文章是关于设置配置参数来优化 Spark 中的内存和 CPU 分配。在这里,我将提到在 Pyspark 中开发时一些有用的编码实现,以提高工作持续时间、内存和 CPU 使用率方面的性能。
1 —通过广播加入
连接两个表是 Spark 中的主要事务之一。它主要需要洗牌,由于节点之间的数据移动,洗牌的成本很高。如果其中一个表足够小,则可能不需要任何洗牌操作。通过将小表广播到集群中的每个节点,可以简单地避免混乱。
假设您正在处理一个力场数据集,并且有一个名为 df_work_order 的数据框,其中包含力场团队处理的工作指令。此外,您还有另一个数据框,其中包含现场工作队的城市信息。虽然 df_work_order 中有超过 100M 行和许多列,但是 df_city 数据帧中大约有 100 条记录。要将城市信息添加到 df_work_order 数据帧中,广播小表就可以了。
df_work_order = df_work_order.join(broadcast(df_city), on=[‘TEAM_NO’], how=’inner’)
广播表的最大大小为 8GB 。Spark 还在内部维护了一个表大小的阈值,以自动应用广播连接。可以使用spark . SQL . autobroadcastjointhreshold配置阈值,默认为 10MB 。
2 —用窗口替换联接&聚合
常见的模式是对特定的列执行聚合,并将结果作为新的特性/列保留在原始表中。正如所料,该操作由一个聚合和一个连接组成。作为一个更优化的选项,可以使用 窗口 类来执行任务。我认为这是一种常见的模式,值得一提。两种方法的简单基准和 DAG(有向无环图)表示可以在这里找到。
# first approachdf_agg = df.groupBy('city', 'team').agg(F.mean('job').alias('job_mean'))df = df.join(df_agg, on=['city', 'team'], how='inner')# second approachfrom pyspark.sql.window import Windowwindow_spec = Window.partitionBy(df['city'], df['team'])
df = df.withColumn('job_mean', F.mean(col('job')).over(window_spec))
3 —最小化洗牌
Spark 操作符通常是流水线式的,在并行进程中执行。然而,一次洗牌打破了这一管道。它们是物化点的种类,并在管道中触发一个新的阶段。在每个阶段结束时,所有中间结果都被具体化,并被下一个阶段使用。在每个阶段中,任务以并行方式运行。
原则上,混洗是数据在网络上的物理移动,并被写入磁盘,导致网络、磁盘 I/O 和数据序列化,从而使混洗成为一项成本高昂的操作。换句话说,它是有原因的数据重新分配。在 Spark 中,这些原因是像加入、被分组、被减少、重新分配和不同这样的变换。这些是非常常见的转换。因此,对于 Spark 应用程序来说,洗牌几乎是不可避免的。然而,减少洗牌是我们的责任。当先前的转换已经根据相同的分割器对数据进行了分区时,Spark 知道如何避免洗牌。
为了减少网络 I/O 在 shuffle 的情况下,可以创建具有更少机器并且每个机器具有更大资源的集群。然而,这完全是一个设计决策,不应该只考虑最小化洗牌。
- 从洗牌点加入
正如我之前提到的,join 是需要 shuffle 的常见操作之一。因为这是一个非常常见的转换,而且 join 中的洗牌可能是可以避免的,所以我想在一个单独的部分中讨论它。Spark 为 joins 提供了三种不同的算法—SortMergeJoin、 ShuffleHashJoin 和 BroadcastHashJoin 。从 2.3 版开始, SortMergeJoin 是默认的连接算法。使用 *BroadcastHashJoin,*可以获得最佳性能,但是,它对数据帧的大小有非常严格的限制。
洗牌或许可以避免,但当然要有所取舍。大多数情况下,通过对同样需要洗牌的数据应用其他转换,可以消除连接过程中的洗牌。重点是你额外创造了多少洗牌,作为回报,你将阻止多少洗牌。此外,每次洗牌的数据量是另一个应该考虑的重要因素——一次大洗牌还是两次小洗牌?所有这些问题的答案都不是直截了当的,如果是,这将是 Spark 的默认行为。这实际上取决于您正在处理的数据。
根据经验,如果在连接中,第一个表的每个分区最多被第二个表的一个分区使用,就没有必要进行洗牌。但是,如果第一个表的每个分区可能被第二个表的多个分区在连接中使用,那么就需要进行洗牌。以这种方式,我们可以在实现连接之前,通过对相同键值的两个表进行重新分区或分桶来避免洗牌。请记住,这些操作也需要洗牌。
加入 Spark 期间洗牌
不避免混洗但减轻混洗中的数据量的典型例子可以是一个大数据帧和一个中等数据帧的连接。如果一个中等大小的数据帧不够小而不能广播,但是它的密钥集足够小,我们可以广播中等大小数据帧的密钥集来过滤大数据帧。通过这种方式,如果我们能够从大规模数据中过滤出大量数据,我们可能会实现大幅减少数据量。
list_to_broadcast = df_medium.select('id').rdd.flatMap(lambda x: x).collect()
df_reduced = df_large.filter(df_large['id'].isin(list_to_broadcast))
df_join = df_reduced.join(df_medium, on=['id'], how='inner')
- 铲斗移动
分桶是另一种数据组织技术,它用相同的桶值对数据进行分组。**它类似于分区,但是分区为每个分区创建一个目录,而分桶通过桶值上的散列将数据分布在固定数量的桶上。**关于存储的信息存储在 metastore 中。它可以在有或没有分区的情况下使用。一个重要的要点是分区应该只用于值数量有限的列;当唯一值的数量很大时,bucketing 也能很好地工作。通常在聚合和连接中用作键的列是分桶的合适候选列。
通过在混洗所需的操作之前对数据帧中的方便列应用桶化,我们可以避免多次可能的昂贵混洗。分桶通过在执行排序-合并连接之前对数据进行排序和混排来提高性能。在连接中,表的两边有相同数量的桶是很重要的。
要使用它,需要指定桶的数量和键列。不用说,我们应该对数据有深入的了解,以决定正确的桶数。一般来说,通过、连接、组,不同的变换受益于桶。
df = df.bucketBy(32, ‘key’).sortBy(‘value’)
任何情况下多洗牌都是好的?
可能会出现两种不同的情况。第一个是关于通过应用额外的洗牌来增加应用的并行度。如果应用程序由于低水平的并行性而无法利用集群中的所有内核,则可以应用重新分区来增加分区数量。这样,通过额外的洗牌,应用程序的整体性能可能会更好。
其次,当在大量分区上聚合时,在合并所有结果的驱动程序中,计算会很快成为单个线程的瓶颈。为了减轻驱动程序的负载,可以执行一轮额外的分布式聚合,通过聚合操作将数据集划分为更少的分区。在将结果发送到驱动程序进行最后一轮聚合之前,每个分区中的值会并行合并。这样,驱动程序中的计算负荷将会减轻。
4 —正确缓存
仅仅因为您可以在内存中缓存数据帧,您就不应该本能地这样做。请记住,执行内存和存储内存共享一个统一的区域。越多不必要的缓存,就越有可能将溢出到磁盘上,从而影响性能。这样,重新计算可能比增加内存压力所付出的代价更快。Spark 中有几个存储级别,可能会根据序列化、内存和数据大小因素进行相应的设置。
如果一个数据帧将在后面的步骤中反复使用,那么在开始时缓存它以避免重复的转换负载将是合理的。这是使用缓存的理想情况。
我经常观察到的一个误用缓存的情况是在从 Cassandra 或 Parquet 这样的数据源读取数据后立即缓存数据帧。在这种情况下,整个数据被缓存,而不检查所有数据是否相关。例如,在从 parquet 读取的情况下,Spark 将只读取元数据来获取计数,因此它不需要扫描整个数据集。对于过滤查询,它将使用列修剪,只扫描相关的列。另一方面,当从缓存中读取数据时,Spark 将读取整个数据集。
需要注意的是,如果您在数据帧上应用哪怕是一个小的事务,比如添加一个带有列的新列*,它将不再存储在缓存中。您可以使用测向存储级别检查数据帧的状态*
5 —打破沿袭—检查点
检查点 截断执行计划,将检查点数据帧保存到磁盘上的临时位置,并重新加载回来,这在除 Spark 之外的任何地方都是多余的。然而,在 Spark 中,它作为一个性能提升因素出现。关键在于,每次在数据框上应用变换或执行查询时,查询计划都会增长。Spark 保存了在数据帧上运行 explain 命令时可以看到的应用于数据帧的所有变换历史。当查询计划开始变大时,性能会急剧下降,从而产生瓶颈。
以这种方式,检查点有助于刷新查询计划和具体化数据。它非常适合包含迭代算法和扩展新数据框架以执行不同类型分析的场景。更确切地说,在对数据帧进行检查点操作后,您不需要重新计算之前应用于数据帧的所有变换,它会永远存储在磁盘上。注意,即使在 sparkContext 被销毁之后,Spark 也不会清理检查点数据,清理工作需要由应用程序来管理。通过检查数据帧的状态来调试数据流水线也是检查点的一个很好的特性。
为了提高性能,缓存也是一种类似目的的替代方法。与检查点相比,它显然需要更多的内存。在缓存和检查点之间有一个很好的比较,以及什么时候更喜欢它们中的一个。这里可以看一下。
还有一个意见这里,数据流水线中检查点放在哪里。在创建 Spark 会话时,可以定义存储数据的位置。
# query plan without checkpointdf = df.filter(df['city'] == 'Ankara')
df = df.join(df1, on = ['job_id'], how='inner')
df.explain()# query plan with checkpointdf = df.filter(df['city'] == 'Ankara').checkpoint()
df = df.join(df1, on = ['job_id'], how=’inner’)
df.explain()
6 —避免使用 UDF
乍一看,用户定义函数(UDF)是以函数方式解决问题的非常有用的材料,它们确实如此。然而,Pyspark 的成本非常高。它们一次操作一行,因此序列化和调用开销很大。换句话说,它们使数据在 executor JVM 和 Python 解释器之间移动,导致了巨大的序列化成本。此外,在调用一个 Python UDF 之后,Spark 会忘记之前数据是如何分布的。因此,与 Java 或 Scala 中的 UDF 实现相比,在 Pyspark 中使用UDF不可避免地会降低性能。
从这个意义上来说,在 Pyspark 中开发时,避免不必要的使用 UDF 是一个很好的实践。内置的 Spark SQL 函数可以满足大部分需求。在 Pyspark 中使用UDF之前,重新思考是很重要的。如果你仍然要使用UDF,考虑使用pandasUDF构建在 Apache Arrow 之上。它承诺完全用 Python 定义低开销、高性能的UDF的能力,从 2.3 版本开始就支持了。作为缓解由UDF引起的性能瓶颈的另一种选择,用 Java 或 Scala 实现的UDF也可以从 PySpark 中调用。
为了更清楚UDF的不必要用法,看一下下面的例子,用UDF计算 z-score 没有任何意义。
# Unnecessary usage of UDFs
z_score_udf = F.udf(lambda x, m, s: (x — m) / s, DoubleType())
df = df.withColumn('z_score',z_score_udf('completed_job',
'mean_completed_job', 'std_completed_job'))# A better approach
df = df.withColumn('z_score',
F.round(((F.col('completed_job') — F.col('mean_completed_job')) /
F.col('std_completed_job')),2))
7 —处理倾斜数据— 加盐&重新分配
整个阶段的工作持续时间直接取决于任务的最长运行时间。如果你在 Spark 上花了足够多的时间,你很可能会遇到这样一种情况:最后一个任务需要几分钟,而这个阶段的其余任务比如说 199 个任务在几毫秒内执行。它是数据沿分区分布不均匀的结果,即数据偏斜问题。这个问题可能发生在火花应用的中间阶段。此外,如果数据高度倾斜,甚至可能导致数据从内存溢出到磁盘。为了观察数据在分区之间的分布,可以使用 glom 函数。此外,借助 Spark UI 的执行器页面中筛选出的任务执行时间和任务处理的数据量信息,也可以检测出数据的不均匀分布。
*partition_number = df.rdd.getNumPartitions()
data_distribution = df.rdd.glom().map(len).collect()*
Spark 3.0 版本有一个很好的特性 自适应查询执行 ,它可以自动平衡分区间的偏斜。除此之外,还有两个独立的解决方法来解决分区间数据分布的偏斜—加盐和重新分区。
- 腌制
加盐 一个数据集基本上就是给数据添加随机化,帮助它更均匀的分布。额外的处理成本是在分区间均匀分布数据的回报,因此性能会提高。在聚合和联接中,具有相同键的所有记录都位于同一个分区中。因此,如果其中一个键比其他键有更多的记录,那么该键的分区就有更多的记录需要处理。 S alting 技术仅应用于倾斜的键,从这个意义上说,随机值被添加到键中。然后,获得*<key 1+random _ salting _ value>*,如果是 join 操作,则将这个创建的新键值与另一个表中复制的对应键值进行匹配。
为了澄清这一点,请看下面的例子,其中键列是 join 中的城市信息,而键列的分布在表中是高度倾斜的。为了均匀分布数据,我们将从 1 到 5 的随机值附加到较大的连接表的键值的末尾,并通过从 1 到 5 展开一个数组在较小的表中组成一个新列。
Spark 中的 Salting 示例
# Adding random values to one side of the join
df_big = df_big.withColumn('city', F.concat(df['city'], F.lit('_'), F.lit(F.floor(F.rand(seed=17) * 5) + 1)))# Exploding corresponding values in other table to match the new values of initial table
df_medium = df_medium.withColumn('city_exploded', F.explode(F.array([F.lit(i) for i in range(1,6)])))
df_medium = df_medium.withColumn('city_exploded', F.concat(df_medium['city'], F.lit('_'), df_medium['city_exploded'])). \
drop('city').withColumnRenamed('city_exploded', 'city')# joining
df_join = df_big.join(df_medium, on=['city'], how='inner')
- 重新分配
重新分区执行完全洗牌,创建新分区,并提高应用程序中的并行级别。更多的分区将有助于处理数据偏斜问题,其额外成本是如上所述的全部数据的混洗。然而,向查询计划添加一个 shuffle 可能会消除另外两个 shuffle,从而加快运行速度。重新分区也可能由特定的列执行。如果在下面的步骤中这些列上存在多个连接或聚合,这将非常有用。
另一种方法是 *coalesce,*不同于 repartition 用于通过洗牌增加或减少分区号,它用于减少分区号而不洗牌。联合可能无法解决数据分布的不平衡问题。
# only set partition number
df = df.repartition(1000)
*# only partition accroding to colums*
*df = df.repartition(['col_1', 'col_2', 'col_3'])
# reparition number and columns together
df.repartition(1000, ['col_1', 'col_2', 'col_3'])*
除了数据偏斜,我强烈推荐看一看这篇文章,它给出了关于重新分配的使用案例,并解释了幕后的细节。
配置输入格式以创建更多的分割并将输入数据以较小的块大小写出到 HDFS 是增加分区数量的其他技术。
8 —利用适当的文件格式—拼花地板
Apache Parquet 是一种列存储格式,旨在只选择被查询的列,跳过其余的列。它通过 Spark 提供了最快的读取性能。 Parquet 将数据排列成列,将相关值放在彼此靠近的位置,以优化查询性能,最大限度地减少 I/O,并促进压缩。此外,它实现了列修剪和谓词下推(基于统计的过滤器),这是一个简单的过程,当查询一个巨大的表时,只选择所需的数据进行处理。它可以防止在内存中加载不必要的数据部分,并减少网络使用。
拼花格式—列修剪和谓词下推
关键是从数据源中只获取相关的数据,而不管您使用什么类型的数据源,并简单地防止全表扫描。它不是 Spark 的直接问题,而是直接影响 Spark 应用程序的性能。
例如,如果您使用 Cassandra ,读取直接分区会非常有效。然而,它有时会变得棘手。假设,Cassandra 表是按日期列分区的,并且您有兴趣读取最近 15 天的数据。在这种情况下,简单地用等于操作符一个接一个地读取 day,然后将它们联合在一起,比用过滤器 > date_current-15 读取要高效得多。
# day_from is the starting point of date info and sequential 15 days are queried.
dfs =list()
for i in range(15):
day_i = day_from + timedelta(days=i)
df = self.sc_session \
.read \
.format('org.apache.spark.sql.cassandra') \
.options(table=self.table, keyspace=self.keyspace) \
.load()
df = df.filter(F.col('PARTITION_KEY_COLUMN') == day_i) # rather than > day_i
dfs.append(df)
df_complete = reduce(DataFrame.union, dfs) # union is a kind of narrow transformation which does not require shuffling
9 —使用带有 pyArrow 的托潘达斯
作为官方定义, Apache Arrow 是一个跨语言的内存数据开发平台。它为平面和层次数据指定了一种标准化的语言无关的列内存格式。更清楚地说, Apache Arrow 是跨语言平台之间的桥梁,它有助于读取 Spark 数据帧,然后将数据帧写入 Apache Cassandra,而不会遭受巨大的低效序列化和反序列化性能。
Apache PyArrow 是 Arrow 的 Python 实现。它提供了一个 Python API,将 Arrow 的功能与 Python 环境结合在一起,包括领先的库,如熊猫和 numpy 。在 Spark 中,数据只要在 JVM 中,处理速度都非常快。然而,由于 Python 中丰富的数据处理库等原因,Pyspark 开发人员可能会在 Python 环境和 JVM 之间转移数据。从这个意义上来说,在从 pandas 数据帧移动到 Spark 数据帧时使用 PyArrow ,或者反之亦然,都会带来巨大的性能提升。
要使用 PyArrow ,首先要通过 pip 或 conda 安装。之后,在配置中启用它就足够了。剩下的都是一样的,编码没有变化。在 Pyspark 应用中使用 pyArrow 以及在 pandas 和 spark 数据帧之间的转换过程中发生了什么在这里解释得非常清楚。
pip install pyarrowspark.conf.set(“spark.sql.execution.arrow.enabled”, “true”)
外卖食品
- 当不需要返回精确的行数时,不要使用 count() 。为了检查数据帧是否为空,考虑到性能问题, len(df.head(1)) > 0 会更准确。
- 不要在你的产品代码中使用 show() 。
- 使用 df.explain() 来深入了解 Spark(物理规划的最终版本)中数据帧的内部表示是一个很好的实践。
- 在连接之前,总是通过过滤不相关的数据(行/列)来尽量减小数据大小。
- 在线/离线监控 Spark 应用。它可能会给你任何关于不平衡的数据分区的线索,作业在哪里被阻塞,以及查询计划。Spark UI 的替代产品可能是 Ganglia 。
- 基本上,避免使用循环。
- 关注内置功能,而不是定制解决方案。
- 确保 join 操作中的键列不包含空值。
- 将较大的数据集放在左边的连接中。
- 请记住,Spark 是以懒惰评估逻辑运行的。因此,在调用动作之前,不会触发任何东西。这可能会导致无意义的错误代码。
- 如果您的其余代码不需要缓存中的数据,请将其取消持久化。
- 完成应用程序后,关闭/停止 Spark 会话。
- 在 Spark 3.0 中,考虑到版本升级,通过 自适应查询执行 解决了性能问题,实现了重大改进。
- 对于数据操作,更喜欢数据帧而不是 rdd。
- 一般来说,大于大约 20 KiB 的任务可能值得优化。
- 一般来说,建议您的集群中每个 CPU 内核执行 2-3 个任务。
- 每个分区有一个在 128MB 内的块来实现并行性总是好的。
- Csv 和 Json 数据文件格式可提供高写入性能,但读取速度较慢,另一方面,拼花文件格式非常快,可提供最佳读取性能,但在写入操作方面比其他提及的文件格式慢。
- 物理计划是自下而上读取的,而 DAG 是自上而下读取的。
- 交换的意思是阶段之间发生了洗牌,基本上是性能下降。
- 过多的阶段数可能是性能问题的迹象。
- 垃圾收集(GC) 是另一个可能导致性能问题的关键因素。从 Spark UI 的执行者标签中查看。在任何与 GC 相关的情况下,通常都可以使用 Java GC 选项。
- 序列化在任何分布式应用程序的性能中也扮演着重要的角色。将对象序列化的速度较慢或消耗大量字节的格式将大大降低计算速度。对于基于 Scala/Java 的 Spark 应用,强烈推荐使用 Kryo 序列化。在 Pyspark 中,支持 Marshal 和 Pickle 序列化器, MarshalSerializer 比 PickleSerializer 快,但支持的数据类型少。
- 注意,如果您更喜欢在 docker 环境中使用 Spark,您可能会体验到性能损失。在我们的项目中,我们观察到,在 docker 环境中,Spark 应用程序使用相同的配置指标需要更长的时间。
有用的链接
- https://towards data science . com/the-art-of-joining-in-spark-dcbd 33d 693 c
- https://medium . com/@ brajendragouda/5-key-factors-to-keep-in-mind-while-optimization-Apache-spark-in-AWS-part-2-c 0197276623 c
- https://medium . com/teads-engineering/spark-performance-tuning-from-the-trench-7 cbde 521 cf 60
- https://medium . com/tblx-insider/how-we-reduced-our-Apache-spark-cluster-cost-using-best-practices-ac1f 176379 AC
- https://medium . com/Expedia-group-tech/part-3-efficient-executor-configuration-for-Apache-spark-b 4602929262
- https://towards data science . com/about-joins-in-spark-3-0-1 E0 ea 083 ea 86
- https://towards data science . com/be-in-charge-of-query-execution-in-spark-SQL-c 83 D1 e 16 b 9 b 8
- https://ch-naba run . medium . com/Apache-spark-optimization-techniques-54864d 4 FDC 0 c
- https://changhsinlee.com/pyspark-dataframe-basics/
- https://robertovitillo.com/spark-best-practices/
- 【https://luminousmen.com/post/spark-tips-partition-tuning
Apache Spark:在应用程序中的并发作业之间公平共享
确保在应用程序中的并发作业之间平等分配资源,而不管它们的大小
Krzysztof Maksimiuk 在 Unsplash 上的照片
注意:在本帖中,短语“spark job”和“job”用于指代 spark 操作,如保存、收集、计数等。,短语“并发作业”是指在一个应用程序中同时运行的多个并行 spark 动作。
在我的 上一篇文章 中,我们讨论了通过 Scala Futures 或 Parallel Collections 并发运行 Spark 作业来提升单调的 Apache Spark 应用程序,这将应用程序时间减少到了四分之一。(如果你之前没看过,现在看就值得了。)
然而,可能存在这样一种情况,即仅在 spark 作业级别实现并发性不足以优化应用程序的性能。例如,一个大型作业消耗所有可用的 spark 资源,并将其他并行作业推入等待状态,直到前者的任务没有利用所有资源。发生这种情况是因为 spark 在应用程序中的默认调度选项是 FIFO(先进先出),这确保了第一个作业优先获得所有可用的 Spark 资源,直到其阶段有任务运行。在大多数这样的场景中,我们绝不会希望 spark 应用程序只被一个长作业卡住,同时,我们希望所有 spark 作业,无论是短作业还是长作业,都能公平地共享资源。
为了满足我们的需求,Apache Spark 提供了一个完美的解决方案,通过它我们可以将默认的调度选项更改为 FAIR,这样任务之间的任务就会以循环方式执行。这意味着所有的工作都获得了同等份额的星火资源。通过使用公平调度,我将我的应用程序的持续时间减少了 27 %,因此,我强烈推荐它用于任何同时运行多个并行 spark 作业的 spark 应用程序。
利用公平调度的先决条件:在 Spark 应用程序中,多个任务从不同的线程提交。如果你不知道怎么做,请看这里的。
此时,我们准备探索如何在具有并发作业的 Spark 应用程序中实现公平调度。
首先创建“ fairscheduler.xml ”,使用您选择的池名和调度模式 FAIR。这里,我们需要明确地提到它的调度模式,因为默认情况下,它是每个池的 FIFO。你可以在这里了解更多。
https://gist . github . com/hariviapak/06310 a6f 62 fe2b 59 ea 37 b 8 D1 cc 051726
其次,我们需要配置 spark 应用程序,以便在创建 spark 会话或提交 Spark 应用程序时使用公平调度。
最后,我们需要将" fairscheduler.xml "中定义的池设置为 spark 上下文的本地属性。
https://gist . github . com/hariviapak/d3ba 23 ea 12082 e 6 f 36 df 124 e 5 f 6 bea 8 a
通过 Spark UI 确认公平调度
确认公平调度是否成功实施是非常重要的,因为在执行上述步骤时稍有差错,就可能使您回到起点。此外,
- 仅将" spark.scheduler.mode “设置为” FAIR "是不够的,因为任务的阶段仍在调度模式为 FIFO 的默认池中运行。
- 这就是我们用公平调度模式创建自己的池的原因。
- 并将该池设置为 spark context 的本地属性。
为了确认我们的工作,我们可以使用 Spark UI。
- 检查“Jobs”页面,确认调度模式是公平的,但是,如前所述,它不能确保池级别的调度也是公平的。
- 因此,转到“阶段”页面,检查正在运行或已完成阶段的池名称。在我们的例子中,应该是“ mypool ”。
下面的片段将帮助你更清楚地理解它。
Spark UI 的工作网页
Spark UI 的 Stages 网页
总之,公平调度是一个必须具备的特性,如果一个应用程序包含并发运行的大小 spark 任务。通过这种方式,我们可以在资源利用方面显著提高应用程序的性能,并最终节省成本。
我希望,你喜欢这篇文章。我很乐意在评论区听到你的建议或反馈。
参考文献
- Apache Spark 官方文档|https://spark.apache.org/docs/latest/job-scheduling.html
- 通过运行多个并行作业提升 Apache Spark 应用Hari Viapak Garg
从人工智能算法到全功能 API
如何简单高效地将你的 AI 算法公开为 API
图片由来自 Pexels 的 Brett Sayles 拍摄
作为一名数据科学家,当与其他开发人员一起从事一个复杂的项目时,你经常需要将你的人工智能算法打包到我们称为API
的东西中,后端可以调用它来协调你的应用程序。使用 API 有几个好处,可以使您的预测更高效、更省时。
在这篇文章中,我们将通过深入研究RESTful
API 的定义,然后我们将通过模块Flask
和FastAPI
使用 python 创建一个 API。最后,我们将看到如何通过Curls
或软件Postman
使用HTTP
协议与它通信。
目录
摘要如下:
- API & RESTful API
- HTTP 协议& CURL &邮递员
- 数据科学算法
- 烧瓶
- FastAPI
API & RESTful API
一个 API,作为一个应用程序的接口,是一个计算机工具,它允许将你的代码打包成一个服务,这个服务可以简单有效地进行交流。
这可以被视为将您的开发转化为一个黑盒的一个步骤,黑盒带有预定义的通信代码,允许您作为provider
随时向团队中的clients
或consumers
前端和后端开发人员公开。
作者图片
有许多免费的 API(天气、航班搜索、足球……)可以在 RapidAPI 上探索。
原料药有多种类型:
- 公共或开放API:无限制
- 私有或内部API:在同一公司内使用
- 合作伙伴API:需要许可证才能访问
如果 API 使两个应用程序能够通信, Web 服务 API,对于它们来说,使给定网络上的两台机器之间的交互成为可能。这是一个使用万维网上的url
来提供对其服务的访问的系统。
web 服务 API 有很多种,比如 SOAP , JSON-RPC , XML-RPC ,…等等。在本文中,我们将主要关注另一种类型的 REST 。与其他协议形式的 web 服务 API 不同,REST 是一套五大架构原则,使得 RESTful web 服务变得轻便、高效、可伸缩且易于使用:
- 客户机-服务器架构:当客户机与服务器通信时,它要么接受请求并发送响应,要么拒绝请求并通知服务器
- 无状态:不在服务器上存储任何信息。这也意味着客户端应该确保所有需要的数据都在请求中
- 可缓存性:在客户端实现,以便在发送旧请求时返回更快的响应
- 分层系统:覆盖附加层的能力,例如,安全层或负载均衡器,而不影响客户机-服务器交换
- 统一接口:简单来说,就是使用
URIs
,为Un formRe sourceI标识器,来公开仓库的结构。结合 HTTP 方法,它允许与服务器进行有效的XML
或JSON
交换。
作者图片
HTTP 协议& CURL & Postman
HTTP 协议
一旦你创建了你的 web 服务 API,你将需要与它通信,这就是 HTTP 开始发挥作用的时候。HTTP,是一种网络通信协议,用于在网络上交换数据。它旨在促进网络服务器和网络导航器(如 Google Chrome 和 Safari)之间的通信。它是一个无状态的协议,遵循客户端-服务器架构,很容易与 RESTful APIs 集成。
作者图片
下面是一些最常用的协议方法:
- POST :在服务器上创建一个资源
- GET :访问服务器上的资源
- 上传:更新服务器上的一个资源
- 删除:删除服务器上的一个资源
卷曲
通常,在虚拟机上工作时,您只能访问一个command line interface
,因为没有graphical interface
,因此也没有导航器。
CURL ,forClientURLRequestLlibrary,之前名为 httpget ,是一个命令行工具,用于获取和发送资源到连接到某个网络的服务器。它支持许多协议,包括 HTTP。有许多 curl 选项,但是在本文中,我们将专注于一个专门针对 RESTful API 的选项:
- -X :确定与服务器通信时使用的 HTTP 方法
邮递员
Postman 是一个简化 API 开发和测试的软件或平台。
通过其用户友好的界面,它提供了一种非常简单的发送请求的方式:
- 选择 HTTP 方法
- 输入 API 正在监听的 URL 和端口
- 选择参数,这将自动更新 HTTP 请求
- 向 API 发送请求
可以在页面的主体部分的底部看到响应。
作者图片
数据科学算法
为了便于说明,我们将考虑鸢尾数据集,其中 ML 任务包括使用四个变量(萼片长度、萼片宽度、花瓣长度和花瓣宽度)将鸢尾分为三类(Setosa、Versicolour 和 Virginica)。
iris 数据集将从 Sklearn 下载,我们将使用随机森林分类器进行训练。
import numpy as np
import pandas as pd
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import confusion_matrix
from sklearn.metrics import accuracy_score
from sklearn.datasets import load_iris
from matplotlib import pyplot as plt
import joblib
%matplotlib inline
WEIGHTS_DIR="weights/"
iris = load_iris()
df=pd.DataFrame(iris.data, columns=iris.feature_names)
df["species"]=iris.target
X = df[iris.feature_names]
y = df['species']
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.20, random_state=42)
clf = RandomForestClassifier(max_depth=2, random_state=42)
clf.fit(X_train, y_train)
y_pred = clf.predict(X_test)
print("Confusion matrix: \n", confusion_matrix(y_test, y_pred))
print("Accuracy score: ", accuracy_score(y_test, y_pred, normalize=True) )
joblib.dump(clf, WEIGHTS_DIR+"clf_iris.joblib")
经过训练,我们得到以下结果:
>>> Confusion matrix:
array([[10, 0, 0],
[ 0, 9, 0],
[ 0, 0, 11]])
>>> Accuracy score: 1.0
在以下段落中,我们将对以下数据集进行预测:
瓶
Flask 是一个 python 模块,用于创建 API 并在给定的网络上公开它们的服务。可以使用以下命令行安装它:
pip install flask
在下面的代码中,我将创建一个 flask API,它使用之前训练的模型来预测虹膜的类别,给定四个变量作为输入。
初始化 API
#%%
from flask import Flask, request, jsonify
import pandas as pd
import joblib
import jsonWEIGHTS_DIR = "weights/"
FLASK_API = Flask(__name__)
加载模型
def get_iris_model():
loaded_clf = joblib.load(WEIGHTS_DIR + "clf_iris.joblib")
return loaded_clfloaded_clf = get_iris_model()def str_to_float_list(arg):
arg = arg.split(",")
arg = [float(x) for x in arg]
return arg
路由
当创建一个 API 时, Routes 用于公开它的功能和服务。在 flask 中,使用 decorators 添加了。
-predict _ class _ postman 我们创建路线,通过该路线我们将进行预测。这条路径返回一个 json 响应,其中包含每组变量的相应类。当使用 Postman 时,我们使用请求的 args 参数提取变量。
#%%Postman
def get_params_postman(request):
sep_length = str_to_float_list(request.args.get("sepLen"))
sep_width = str_to_float_list(request.args.get("sepWid"))
pet_length = str_to_float_list(request.args.get("petLen"))
pet_width = str_to_float_list(request.args.get("petWid")) return (sep_length, sep_width, pet_length, pet_width)@FLASK_API.route("/predict_class_postman", methods=["GET", "POST"])
def predict_class_postman():
(sep_length, sep_width, pet_length, pet_width) = get_params_postman(request)
new_row = pd.DataFrame(
{
"sepal length (cm)": [float(x) for x in sep_length],
"sepal width (cm)": [float(x) for x in sep_width],
"petal length (cm)": [float(x) for x in pet_length],
"petal width (cm)": [float(x) for x in pet_width],
}
)
y_pred = list(loaded_clf.predict(new_row))
y_pred = [str(x) for x in y_pred] response = {"y_pred": ",".join(y_pred)}
return jsonify(response)
-predict _ class _ curl 我们这次创建另一条路由来与 CURL 命令通信。我们使用发送的请求的方法 form.get 从命令行提取变量。
#%%CURL
def get_params_curl(request):
request_input = request.form.get("input")
request_input = json.loads(request_input) sep_length = str_to_float_list(request_input["sepLen"])
sep_width = str_to_float_list(request_input["sepWid"])
pet_length = str_to_float_list(request_input["petLen"])
pet_width = str_to_float_list(request_input["petWid"]) return (sep_length, sep_width, pet_length, pet_width) @FLASK_API.route("/predict_class_curl", methods=["GET", "POST"])
def predict_class_curl():
(sep_length, sep_width, pet_length, pet_width) = get_params_curl(request)
new_row = pd.DataFrame(
{
"sepal length (cm)": [float(x) for x in sep_length],
"sepal width (cm)": [float(x) for x in sep_width],
"petal length (cm)": [float(x) for x in pet_length],
"petal width (cm)": [float(x) for x in pet_width],
}
)
y_pred = list(loaded_clf.predict(new_row))
y_pred = [str(x) for x in y_pred] response = {"y_pred": ",".join(y_pred)}
return jsonify(response)
启动服务
一旦我们定义了上面的所有元素,我们通过添加以下代码来启动 API 的服务:
#%%
if __name__ == "__main__":
FLASK_API.debug = True
FLASK_API.run(host="0.0.0.0", port="8080")
debug mode
有助于即时可视化变化- 我们可以选择暴露 API 的
URL
和port
:
要启动 API,请键入:
python flask_api.py
其中flask_api.py
是托管上面开发的所有代码的文件。
我们得到以下响应:
>>> * Serving Flask app "flask_api" (lazy loading)
>>> * Environment: production
WARNING: This is a development server. Do not use it in a production deployment.
Use a production WSGI server instead.
>>> * Debug mode: on
>>> * Running on [http://0.0.0.0:8080/](http://0.0.0.0:8080/) (Press CTRL+C to quit)
>>> * Restarting with fsevents reloader
>>> * Debugger is active!
>>> * Debugger PIN: 514-546-929
请求&响应
+邮递员
给定了HTTP request
上的邮递员:
localhost:8080/predict_class_postman?sepLen=1,5&sepWid=2,6&petLen=3,7&petWid=4,8
答复如下:
{
"y_pred": "1,2"
}
作者图片
+ CURL
我们使用 CURL 启动以下命令行来与 API 通信:
curl -F "input={\"sepLen\":\"1,5\",\"sepWid\":\"2,6\",\"petLen\":\"3,7\",\"petWid\":\"4,8\"}" -X POST "http://0.0.0.0:8080/predict_class_curl"
正如所料,我们得到了相同的结果:
{
"y_pred": "1,2"
}
作者图片
**HTTP 代码:**如果请求正确,API 返回 HTTP 代码 200 。还存在其他代码,如客户端错误的 4xx 和服务器错误的 5xx 。
作者图片
你可以在我的 GitHub 资源库中找到 flask API 的代码。
FastAPI
FastAPI 是另一个支持 API 开发的 python 模块。
可以使用命令行进行安装:
pip install fastapi
它与 Flask 非常相似,但速度更快,有一些细微的变化:
- 使用
request.query_params
提取postman
中的查询参数 - 使用
eval(input)
获得curls
中的形状参数,其中input: str = Form(...)
#%%
import pandas as pd
import joblib
import json
from fastapi import FastAPI, Form, Request
import uvicorn
WEIGHTS_DIR = "weights/"
FASTAPI_API = FastAPI()
#%%
def get_iris_model():
loaded_clf = joblib.load(WEIGHTS_DIR + "clf_iris.joblib")
return loaded_clf
def str_to_float_list(arg):
arg = arg.split(",")
arg = [float(x) for x in arg]
return arg
loaded_clf = get_iris_model()
#%%Postman
def get_params_postman(query_params):
sep_length = str_to_float_list(query_params["sepLen"])
sep_width = str_to_float_list(query_params["sepWid"])
pet_length = str_to_float_list(query_params["petLen"])
pet_width = str_to_float_list(query_params["petWid"])
return (sep_length, sep_width, pet_length, pet_width)
@FASTAPI_API.post("/predict_class_postman")
def predict_class_postman(request: Request):
query_params = dict(request.query_params)
(sep_length, sep_width, pet_length, pet_width) = get_params_postman(query_params)
new_row = pd.DataFrame(
{
"sepal length (cm)": [float(x) for x in sep_length],
"sepal width (cm)": [float(x) for x in sep_width],
"petal length (cm)": [float(x) for x in pet_length],
"petal width (cm)": [float(x) for x in pet_width],
}
)
y_pred = list(loaded_clf.predict(new_row))
y_pred = [str(x) for x in y_pred]
response = {"y_pred": ",".join(y_pred)}
return response
#%%CURL
def get_params_curls(input_var):
sep_length = str_to_float_list(input_var["sepLen"])
sep_width = str_to_float_list(input_var["sepWid"])
pet_length = str_to_float_list(input_var["petLen"])
pet_width = str_to_float_list(input_var["petWid"])
return (sep_length, sep_width, pet_length, pet_width)
@FASTAPI_API.post("/predict_class_curl")
def predict_class_curl(input: str = Form(...)):
input_var = eval(input)
(sep_length, sep_width, pet_length, pet_width) = get_params_curls(input_var)
new_row = pd.DataFrame(
{
"sepal length (cm)": [float(x) for x in sep_length],
"sepal width (cm)": [float(x) for x in sep_width],
"petal length (cm)": [float(x) for x in pet_length],
"petal width (cm)": [float(x) for x in pet_width],
}
)
y_pred = list(loaded_clf.predict(new_row))
y_pred = [str(x) for x in y_pred]
response = {"y_pred": ",".join(y_pred)}
return response
#%%
if __name__ == "__main__":
uvicorn.run(FASTAPI_API, host="0.0.0.0", port=8080)
FastAPI 是使用uvicon运行的。这是一个闪电般快速的 ASGI 服务器实现,基于 uvloop 和 httptools,其中 uvloop 是 asyncio 事件循环的基于 Cython 的替代,比默认事件循环快 2-4 倍。
可以使用以下命令行安装 Uvicorn:
pip install uvicorn
要启动 API,请键入:
python fastapi_api.py
其中fastapi_api.py
是托管上面开发的所有代码的文件。
我们得到以下响应:
>>> INFO: Started server process [50003]
>>> INFO: Waiting for application startup.
>>> INFO: Application startup complete.
>>> INFO: Uvicorn running on [http://0.0.0.0:8080](http://0.0.0.0:8080) (Press CTRL+C to quit)
请求和回应
+ Postman
给定对 Postman 的 HTTP 请求:
localhost:8080/predict_class_postman?sepLen=1,5&sepWid=2,6&petLen=3,7&petWid=4,8
答复如下:
{
"y_pred": "1,2"
}
作者图片
+ CURL
我们使用 CURL 启动以下命令行与 API 进行通信:
curl -F "input={\"sepLen\":\"1,5\",\"sepWid\":\"2,6\",\"petLen\":\"3,7\",\"petWid\":\"4,8\"}" -X POST "http://0.0.0.0:8080/predict_class_curl"
正如所料,我们用一个 200 HTTP 代码得到了相同的结果:
{
"y_pred": "1,2"
}
作者图片
你可以在我的 GitHub 资源库中找到 FastAPI API 的代码。
结论
API 是非常强大的工具,允许您向服务公开您的工作,并促进与服务的通信。当在一个开发团队中工作时,掌握这些技术对于项目的进展变得至关重要。
API 与 R 的交互
实践教程
演示与 NHL API 交互的插图
这个文档是一个小插图,展示了如何从一个 API 中检索数据。为了演示,我将与 NHL API 进行交互。我将构建一些函数来与一些端点进行交互,并探索一些我可以检索的数据。
需要注意的是,其中一些函数返回团队级别的数据。一些 API 使用特许 ID 号,而一些使用最近的团队 ID 来选择特定团队的端点。因此,如果您使用我的任何功能,我建议您提供完整的团队名称(例如"Montréal Canadiens"
)。我的函数会把它们解码成合适的 ID 号。
这篇文章很长,所以如果需要编写函数与 API 交互的例子,只需阅读前半部分。
我在文章的底部提供了一个到 github pages 版本的链接,它提供了一个到 github repo 的链接。
要求
为了使用与 NHL API 交互的函数,我使用了以下包:
[tidyverse](https://www.tidyverse.org/)
:大量有用的数据操作和可视化特性[jsonlite](https://cran.r-project.org/web/packages/jsonlite/)
: API 交互
除了这些包之外,我还在文档的其余部分使用了以下包:
[cowplot](https://cran.r-project.org/web/packages/cowplot/index.html)
:针对ggplot2
的额外功能[imager](https://cran.r-project.org/web/packages/imager/)
:载入图像[broom](https://cran.r-project.org/web/packages/broom/vignettes/broom.html)
:整理回归输出进行显示- 以友好的方式显示表格
API 交互功能
在这里,我定义了与 NHL 记录 API 和 NHL 统计 API 交互的函数,以及一些辅助函数。
convertToNumeric
我创建了这个助手函数来将包含存储为character
值的数字数据的列转换为数字数据类型。我遇到了一个问题,我的 API 调用将一些数字数据作为character
数据返回,我需要一种方法来处理这个问题,而不只是根据需要调用as.numeric
。
convertToNumeric **<-** **function**(vec){
*###*
*# This function will convert the input vector to a numeric vector
# if it is able to. Otherwise, it just returns the vector.*
*###*
*# If any of the values in vec return NA when trying to convert to
# numeric, set output to the unchanged input.*
**if** (**any**(**is.na**(suppressWarnings(**as.numeric**(vec))) **==** **TRUE**)){
output **<-** vec
}
*# Otherwise, convert vec to a numeric vector.*
**else** {
output **<-** **as.numeric**(vec)
}
*# Return output.*
**return**(output)
}
franchise
我编写这个函数是为了与 NHL Records API 的franchise
端点进行交互。它返回一个data.frame
,包含 NHL 历史上每个球队的第一个和最后一个赛季的球队和当前球队 Id 号、球队的名称和缩写。随着地点的改变,一些球队已经改变了球队的名字。它需要一个参数。team
,可以是"all"
,球队全称(如"New Jersey Devils"
),也可以是球队 Id (如23
新泽西魔鬼队)。
franchise **<-** **function**(team**=**"all"){
*###*
*# This functions returns a data.frame with metadata on NHL teams.
# It can also return those columns for a single team if a
# franchise ID or name is passed.*
*###*
*# Get the franchise data from the franchises endpoint.*
outputAPI **<-** fromJSON(
"https://records.nhl.com/site/api/franchise"
)
*# Select only the data.frame from the JSON output.*
output **<-** outputAPI**$**data
*# If team does not equal "all", check if it is a franchise ID or
# team name.*
**if** (team **!=** "all"){
*# If team is in the id column, subset output for just that row.*
**if** (team **%in%** output**$**id){
output **<-** output **%>%**
filter(id **==** team)
}
*# If team is in the fullName column, subset output for just that
# row.*
**else** **if** (team **%in%** output**$**fullName){
output **<-** output **%>%**
filter(fullName **==** team)
}
*# Otherwise, throw an informative error.*
**else** {
message **<-** paste("ERROR: Argument for team was not found in ",
"either the fullName or id columns. Try ",
"franchise('all') to find the franchise ",
"you're looking for.")
stop(message)
}
}
*# Do nothing if the team value equals "all".*
**else** {
}
*# Convert any columns that should be numeric to numeric, while
# suppressing messages.*
output **<-** suppressMessages(
as.data.frame(lapply(output, convertToNumeric)))
*# Return the output data.frame.*
**return**(output)
}
teamTotals
我编写这个函数是为了与 NHL 记录 API 的franchise-team-totals
端点进行交互。它返回了球队整个历史上常规赛和季后赛的大量统计数据。它需要一个参数。team
,可以是"all"
,球队全称(如"New Jersey Devils"
),也可以是球队 Id (如1
新泽西魔鬼队)。
teamTotals **<-** **function**(team**=**"all"){
*###*
*# This function returns total stats for every franchise (ex
# roadTies, roadWins, etc) unless a specific team Id or full team
# name is passed. Then it returns that data for the specific team.
# The output is a data.frame.*
*###*
*# Get the franchise data from the franchises endpoint.*
outputAPI **<-** fromJSON(
"https://records.nhl.com/site/api/franchise-team-totals"
)
*# Select only the data.frame from the JSON output.*
output **<-** outputAPI**$**data
*# If team does not equal "all", check if it is a team ID or team
# name.*
**if** (team **!=** "all"){
*# If team is in the teamId column, subset output for just that
# row.*
**if** (team **%in%** output**$**teamId){
output **<-** output **%>%**
filter(teamId **==** team)
}
*# If team is in the teamName column, subset output for just that
# row.*
**else** **if** (team **%in%** output**$**teamName){
output **<-** output **%>%**
filter(teamName **==** team)
}
*# Otherwise, warn the user and return the entire dataframe.*
**else** {
message **<-** paste("WARNING: Argument for team was not found ",
"in either the teamName or franchiseId ",
"columns. Returning all franchises.")
warning(message)
}
}
*# Do nothing if the team value equals "all".*
**else** {
}
*# Convert any columns that should be numeric to numeric values.*
output **<-** suppressMessages(
as.data.frame(lapply(output, convertToNumeric)))
*# Return the output data.frame.*
**return**(output)
}
findId
这是一个帮助功能,用于查找最新的球队 Id 或球队 Id,以获得完整的球队名称(例如findId("Boston Bruins", "team")
或findId("Boston Bruins", "franchise")
)。它用于为 API 端点找到合适的 Id 号。
findId **<-** **function**(teamName, idType){
*# Call the teamTotals function with the team name so we can look
# up the appropriate Id from it.*
outputAPI **<-** teamTotals(teamName)
*# Retrieve the franchise Id if that is what the idType is.*
**if** (idType **==** "franchise"){
output **<-** outputAPI[1,]**$**franchiseId
}
*# Retrieve the team Id if that is what the idType is.*
**else** **if** (idType **==** "team"){
output **<-** outputAPI[1,]**$**teamId
}
*# Any other argument throws an error.*
**else** {
stop(paste("ERROR: Invalid idType argument! Should be ",
"'franchise' or 'team'!"))
}
*# Return the appropriate Id.*
**return**(output)
}
seasonRecords
seasonRecords
函数返回单个专营权的记录统计数据。比如一个赛季进球最多的,以及他们进球的赛季。它需要一个参数。team
,可以是球队全称(如"New Jersey Devils"
),也可以是球队 Id (如23
新泽西魔鬼队)。
seasonRecords **<-** **function**(team){
*###*
*# This functions returns a data.frame with the season records for
# a variety of stats for a single team.*
*###*
*# If team is a "character" type, try to look up the franchise id.*
**if** (typeof(team) **==** "character"){
teamId **=** findId(team, "franchise")
}
*# If team is an integer, set teamId equal to team.*
**else** **if** ((typeof(team) **==** "double") **&** (team **%%** 1 **==** 0)){
teamId **=** team
}
*# Otherwise, throw an error.*
**else** {
message **<-** paste("ERROR: Please pass a franchise id (integer) ",
"or a full team name (e.g. 'Boston Bruins').")
stop(message)
}
*# Set the base url, endpoint, and combine them with teamId for the
# full url.*
baseURL **<-** "https://records.nhl.com/site/api/"
endpoint **<-** "franchise-season-records?cayenneExp=franchiseId="
fullURL **<-** paste0(baseURL, endpoint, teamId)
*# Get the API output.*
outputAPI **<-** fromJSON(fullURL)
*# Select only the data from the API output.*
output **<-** outputAPI**$**data
*# Convert any columns that should be numeric to numeric format.*
output **<-** suppressMessages(
as.data.frame(lapply(output, convertToNumeric)))
*# Return the output from the request.*
**return**(output)
}
goalieRecords
goalieRecords
函数返回为一个球队效力的所有守门员的统计数据。比如一场比赛最多的扑救,比赛日期。它需要一个参数。team
,可以是球队全称(如"New Jersey Devils"
),也可以是球队 Id (如23
新泽西魔鬼队)。
goalieRecords **<-** **function**(team){
*###*
*# This functions returns a data.frame with the goalie records for
# a team.*
*###*
*# If team is a "character" type, try to look up the franchise id.*
**if** (typeof(team) **==** "character"){
teamId **=** findId(team, "franchise")
}
*# If team is an integer, set teamId equal to team.*
**else** **if** ((typeof(team) **==** "double") **&** (team **%%** 1 **==** 0)){
teamId **=** team
}
*# Otherwise, throw an error.*
**else** {
message **<-** paste("ERROR: Please pass a franchise id (integer) ",
"or a full team name (e.g. 'Boston Bruins').")
stop(message)
}
*# Set the base url, endpoint, and combine them with teamId for the
# full url.*
baseURL **<-** "https://records.nhl.com/site/api/"
endpoint **<-** "franchise-goalie-records?cayenneExp=franchiseId="
fullURL **<-** paste0(baseURL, endpoint, teamId)
*# Get the API output.*
outputAPI **<-** fromJSON(fullURL)
*# Select only the data from the JSON output.*
output **<-** outputAPI**$**data
*# Convert any columns that should be numeric to numeric format.*
output **<-** suppressMessages(
as.data.frame(lapply(output, convertToNumeric)))
*# Return the output from the request.*
**return**(output)
}
skaterRecords
skaterRecords
函数返回所有为一个球队效力的非守门员球员的数据。例如,他们在一个赛季中被罚的时间最多,以及这个赛季发生的时间。它需要一个参数。team
,可以是球队全称(如"New Jersey Devils"
),也可以是球队 Id (如23
新泽西魔鬼队)。
skaterRecords **<-** **function**(team){
*###*
*# This functions returns a data.frame with the skater records for
# a team.*
*###*
*# If team is a "character" type, try to look up the franchise id.*
**if** (typeof(team) **==** "character"){
teamId **=** findId(team, "franchise")
}
*# If team is an integer, set teamId equal to team.*
**else** **if** ((typeof(team) **==** "double") **&** (team **%%** 1 **==** 0)){
teamId **=** team
}
*# Otherwise, throw an error.*
**else** {
message **<-** paste("ERROR: Please pass a franchise id (integer) ",
"or a full team name (e.g. 'Boston Bruins').")
stop(message)
}
*# Set the base url, endpoint, and combine them with teamId for the
# full url.*
baseURL **<-** "https://records.nhl.com/site/api/"
endpoint **<-** "franchise-skater-records?cayenneExp=franchiseId="
fullURL **<-** paste0(baseURL, endpoint, teamId)
*# Get the API output.*
outputAPI **<-** fromJSON(fullURL)
*# Select only the data from the JSON output.*
output **<-** outputAPI**$**data
*# Convert any columns that should be numeric to numeric format.*
output **<-** suppressMessages(
as.data.frame(lapply(output, convertToNumeric)))
*# Return the output from the request.*
**return**(output)
}
franchiseDetail
这个函数获取特许经营权的信息,比如他们的退休号码。它需要一个参数。team
,可以是球队全称(如"New Jersey Devils"
),也可以是最近的球队 Id (如1
新泽西魔鬼队)。
franchiseDetail **<-** **function**(team){
*###*
*# This functions returns a data.frame with the data for a team.*
*###*
*# If team is a "character" type, try to look up the team id.*
**if** (typeof(team) **==** "character"){
teamId **=** findId(team, "team")
}
*# If team is an integer, set teamId equal to team.*
**else** **if** ((typeof(team) **==** "double") **&** (team **%%** 1 **==** 0)){
teamId **=** team
}
*# Otherwise, throw an error.*
**else** {
message **<-** paste("ERROR: Please pass a team id (integer) or ",
"a full team name (e.g. 'Boston Bruins').")
stop(message)
}
*# Set the base url, endpoint, and combine them with teamId for the
# full url.*
baseURL **<-** "https://records.nhl.com/site/api/"
endpoint **<-** "franchise-detail?cayenneExp=mostRecentTeamId="
fullURL **<-** paste0(baseURL, endpoint, teamId)
*# Get the API output.*
outputAPI **<-** fromJSON(fullURL)
*# Select only the data from the JSON output.*
output **<-** outputAPI**$**data
*# If the output has no rows after becoming a data.frame, then the
# team is now a different team under an active franchise. Then we
# need to re-run the query with the appropriate team Id.*
**if** (nrow(as.data.frame(output)) **==** 0){
*# Find the most recent team Id for this deprecated team that is
# in the history of the active franchise.*
teamId **<-** franchise(findId(team, "franchise"))**$**mostRecentTeamId
*# Combine the URL components with teamId for the full url.*
fullURL **<-** paste0(baseURL, endpoint, teamId)
*# Get the API output.*
outputAPI **<-** fromJSON(fullURL)
*# Select only the data from the JSON output.*
output **<-** outputAPI**$**data
}
*# Convert any columns that should be numeric to numeric format.*
output **<-** suppressMessages(
as.data.frame(lapply(output, convertToNumeric)))
*# Return the output from the request.*
**return**(output)
}
seasonStats
这个函数返回一个包含当前赛季统计数据的data.frame
。它需要一个参数。team
,可以是"all"
,现役球队的全称(如"New Jersey Devils"
),也可以是最近的球队 Id (如1
新泽西魔鬼队)。
seasonStats **<-** **function**(team**=**"all", raw**=FALSE**){
*###*
*# Returns the current seasons stats for all teams or just one. If
# raw is FALSE, it returns only the stats. If raw is TRUE, it just
# returns the API output for the user to parse.*
*###*
*# If raw is not a valid input, throw an error.*
**if** ((raw **!=** **TRUE**) **&** (raw **!=** **FALSE**)){
stop("ERROR: Argument for raw must equal TRUE or FALSE!")
}
*# Otherwise do nothing.*
**else** {
}
*# If team equals "all" the spot where ID goes in the URL will be
# blank.*
**if** (team **==** "all"){
teamId **=** ""
}
*# If team is a "character" type, try to look up the team id.*
**else** **if** (typeof(team) **==** "character"){
teamId **=** findId(team, "team")
}
*# If team is an integer, set teamId equal to team.*
**else** **if** ((typeof(team) **==** "double") **&** (team **%%** 1 **==** 0)){
teamId **=** team
}
*# Otherwise, throw an error.*
**else** {
message **<-** paste("ERROR: Please pass a team id (integer) or ",
"a full team name (e.g. 'Boston Bruins').")
stop(message)
}
*# Paste together the endpoint URL from its components.*
baseURL **<-** "https://statsapi.web.nhl.com/api/v1/teams/"
modifier **<-** "?expand=team.stats"
fullURL **<-** paste0(baseURL, teamId, modifier)
*# Get the data from the endpoint.*
outputAPI **<-** fromJSON(fullURL, flatten**=TRUE**)
*# If the user doesn't want every team's data, execute this chunk.*
**if** (team **!=** "all"){
*# If raw is FALSE, give back only the stats.*
**if** (raw **==** **FALSE**){
*# Navigate through the columns and list indices to select the
# stats only.*
teamStats **<-** outputAPI**$**teams**$**teamStats[[1]]**$**splits[[1]][1,]
*# Convert any columns that should be numeric to numeric.*
teamStats **<-** suppressMessages(
as.data.frame(lapply(teamStats,
convertToNumeric)))
}
*# If the user wants the raw API output, give it to them so they
# can parse it themselves.*
**else** {
teamStats **<-** outputAPI**$**teams
}
}
*# Otherwise, return them the data for all teams.*
**else** {
*# If raw is FALSE, give back only the stats.*
**if** (raw **==** **FALSE**){
*# Get the teamStats list where each element is a data.frame.*
output **<-** outputAPI**$**teams**$**teamStats
*# Count the number of teams. The last element is NULL.*
num_teams **=** **length**(output) **-** 1
*# Make a variable to hold just the stats, starting with the
# first team.*
teamStats **<-** output[[1]]**$**splits[[1]][1,]
*# Loop through the 2nd to the last team in the list.*
**for** (i **in** seq(2, num_teams)){
*# Select only the first row of the seasons stats for the
# team.*
stats **<-** output[[i]]**$**splits[[1]][1,]
*# Add the row to teamStats.*
teamStats **<-** rbind(teamStats, stats)
}
*# Convert any columns that should be numeric to numeric format.*
teamStats **<-** suppressMessages(
as.data.frame(lapply(teamStats,
convertToNumeric)))
}
*# If the user wants the raw API output, give it to them so they
# can parse it themselves.*
**else** {
teamStats **<-** outputAPI**$**teams
}
}
*# Return teamStats.*
**return**(teamStats)
}
nhlAPI
这个函数是上面所有其他函数的包装函数。您只需传递想要使用的函数名,比如"seasonStats"
,以及该函数的任何附加参数。
nhlAPI **<-** **function**(func, ...){
*###*
*# This function is a wrapper for the other functions. It takes in
# the name of the function to use as a character and any
# additional arguments for that function.*
*###*
*# Find and call the appropriate function using conditional logic.*
**if** (func **==** "franchise"){
output **<-** franchise(...)
}
**else** **if** (func **==** "teamTotals"){
output **<-** teamTotals(...)
}
**else** **if** (func **==** "seasonRecords"){
output **<-** seasonRecords(...)
}
**else** **if** (func **==** "goalieRecords"){
output **<-** goalieRecords(...)
}
**else** **if** (func **==** "skaterRecords"){
output **<-** skaterRecords(...)
}
**else** **if** (func **==** "franchiseDetail"){
output **<-** franchiseDetail(...)
}
**else** **if** (func **==** "seasonStats"){
output **<-** seasonStats(...)
}
**else** {
stop("ERROR: Argument for func is not valid!")
}
*# Return the output from the appropriate function.*
**return**(output)
}
数据探索
现在我们可以与 NHL API 的一些端点进行交互,让我们从它们那里获取一些数据。
首先,让我们通过调用nhlAPI("seasonStats")
来获取所有球队的当前赛季统计数据。
*# Get the current season stats for all of the teams in the NHL.*
currentSeason **<-** nhlAPI("seasonStats")
我感兴趣的两个变量是场均投篮次数和投篮命中率。我对这两个数据与球队胜率的关系很感兴趣。这个变量不存在,我需要计算一下。我把这个定义为赢的次数除以比赛的总次数。
*# Add a column for the win percentage.*
currentSeason **<-** currentSeason **%>%**
mutate(winPercentage **=** stat.wins **/** stat.gamesPlayed)
我的猜测是,这两者与胜率正相关。高投篮命中率意味着你很有可能在投篮时得分。每场比赛的高投篮次数可能意味着你比其他球队控制球更多,你有更多的得分机会,除非你投篮命中率高。这两个数据似乎都是进攻力量的伟大代表。
下面我绘制了每场比赛的胜率和投篮命中率。我还添加了一条回归线。正如所料,两者都与胜率正相关。
*# Create a scatter plot of win pct vs. shots per game.*
plot1 **<-** ggplot(currentSeason, aes(stat.shotsPerGame,
winPercentage,
color**=**winPercentage)) **+**
*# Add a scatter plot layer and adjust the size and opaqueness of
# points.*
geom_point(size**=**4, alpha**=**0.75) **+**
*# Add a color gradient for winPercentage.*
scale_color_gradient(low**=**"blue", high**=**"red") **+**
*# Remove the legend because it takes up space.*
theme(legend.position**=**"none") **+**
*# Add a black regression line.*
geom_smooth(method**=**lm, formula**=**y**~**x, color**=**"black") **+**
*# Add labels to the axes.*
scale_x_continuous("Shots per Game") **+**
scale_y_continuous("Win Percentage") **+**
*# Add a title.*
ggtitle("Win Pct. vs. Shots per Game")*# Create a scatter plot of win pct vs. shooting pct.*
plot2 **<-** ggplot(currentSeason, aes(stat.shootingPctg,
winPercentage,
color**=**winPercentage)) **+**
*# Add a scatter plot layer and adjust the size and opaqueness of
# points.*
geom_point(size**=**4, alpha**=**0.75) **+**
*# Add a color gradient for winPercentage.*
scale_color_gradient(low**=**"blue", high**=**"red") **+**
*# Remove the legend because it takes up space.*
theme(legend.position**=**"none") **+**
*# Add a black regression line.*
geom_smooth(method**=**lm, formula**=**y**~**x, color**=**"black") **+**
*# Add labels to the axes.*
scale_x_continuous("Shooting Percentage") **+**
scale_y_continuous("Win Percentage") **+**
*# Add a title.*
ggtitle("Win Pct. vs. Shooting Pct.")*# Plot them side-by-side.*
plot_grid(plot1, plot2, ncol**=**2)
作者图片
现在让我们看看投篮命中率和场均投篮次数。我为获胜百分比添加了一个颜色渐变。
*# Create a scatter plot of shooting pct vs. shots per game.*
plot3 **<-** ggplot(currentSeason, aes(stat.shotsPerGame,
stat.shootingPctg,
color**=**winPercentage)) **+**
*# Add a scatter plot layer and adjust the size and opaqueness of
# points.*
geom_point(size**=**4, alpha**=**0.75) **+**
*# Add a color gradient for winPercentage with an improved label.*
scale_color_gradient(low**=**"blue", high**=**"red", name**=**"Win Pct.") **+**
*# Add labels to the axes.*
scale_x_continuous("Shots per Game") **+**
scale_y_continuous("Shooting Percentage") **+**
*# Add a title.*
ggtitle("Shooting Pct. vs. Shots per Game") *# Show the plot.*
plot3
作者图片
这两个变量之间似乎没有明确的关系,而我认为可能有。让我们看看另一个端点的数据。
让我们来看看所有参加过 NHL 的球队的历史数据。请注意,其中一些球队属于同一支球队。我将属于同一支球队的球队视为独立的球队(例如科罗拉多洛矶队和新泽西魔鬼队)。我调用了nhlAPI("teamTotals")
来获取这个数据。
*# Get some stats for the total history of a team.*
teamTotalStats **<-** nhlAPI("teamTotals")
首先,让我们看看非活动团队与活动团队的数量。
teamStatus **<-** teamTotalStats **%>%**
*# Filter for regular season stats.*
filter(gameTypeId **==** 2) **%>%**
*# Create a column that tells whether a team is active or not.*
mutate(currentlyActive **=** ifelse(**is.na**(lastSeasonId),
"Active", "Inactive")) **%>%**
*# Select the teamName and activity status columns.*
select(teamName, currentlyActive)*# Count the number of active and inactive teams.*
numActive **<-** **sum**(teamStatus **==**"Active")
numInactive **<-** **sum**(teamStatus **==**"Inactive")
有 26 个不活跃的团队和 31 个活跃的团队(只是提醒一下,我把转移到其他地方的团队视为不活跃的)。
不是所有的球队都有相同的任期,我想调整一些统计数据,以便能够在相同的基础上比较球队。我对各队的点球时间和记录特别感兴趣。
为了在相同的基础上得到数字,当比赛次数如此不同时,我计算每场比赛的罚分钟数,总罚分钟数除以总比赛次数。我再次计算获胜百分比,即获胜次数除以游戏次数。
我还对季后赛或常规赛如何影响罚球时间感兴趣,因为我认为季后赛的重要性可能会导致罚球行为的变化。
此外,我还创建了一个列,recordType
,用于指示一个团队是否有输赢记录。如果赢的百分比大于 0.5,他们被分类为有赢的记录,否则为输的记录。
teamTotalStats **<-** teamTotalStats **%>%**
*# Add columns for penalty minutes per game, win percentage, a text*
*# representation of the game type, and whether a team has a losing
# or winning record for the game type.*
mutate(penaltyMinutesPerGame **=** penaltyMinutes **/** gamesPlayed,
winPercentage **=** wins **/** gamesPlayed,
gameType **=** ifelse(gameTypeId **==** 2, "Regular Season",
"Playoffs"),
recordType **=** ifelse(wins **>** losses, "Winning Record",
"Losing Record"))
下表显示了按游戏类型统计的有输赢记录的活跃队伍的数量。
*# Filter for active teams by looking for missing values in
# lastSeasonId.*
activeTeams **<-** teamTotalStats **%>%**
filter((**is.na**(lastSeasonId) **==** **TRUE**))*# Display a table of the game types by record types for active
# teams.*
knitr**::**kable(table(activeTeams**$**gameType, activeTeams**$**recordType),
caption**=**paste("Counts of Franchise Record Types by ",
"Game Type for Active Teams"))
现役球队按游戏类型统计的特许经营记录类型(图片由作者提供)
下面是不活跃团队的相同表格。从这些表格中可以清楚地看出,不活跃的队伍比活跃的队伍要差得多。他们可能很难吸引观众和销售商品,因为他们太差了。这可能是它们不再存在的原因。
*# Filter for only inactive teams by looking for rows where
# lastSeasonId is not missing.*
inactiveTeams **<-** teamTotalStats **%>%**
filter((**is.na**(lastSeasonId) **==** **FALSE**))*# Count the number of inactive teams using the number of teams with
# regular season games.*
numInactiveTeams **<-** **dim**(filter(inactiveTeams, gameTypeId **==** 2))[1]*# Count the number of inactive teams that made it to the playoffs,
# which is not all of the inactive teams.*
numInactiveTeamsInPlayoffs **<-** **dim**(
filter(inactiveTeams, gameTypeId **==** 3))[1]*# Count the number of inactive teams who did not make the playoffs.*
numDidntMakePlayoffs **<-** numInactiveTeams**-**numInactiveTeamsInPlayoffs*# Create an index for the last row in inactive teams.*
currentEndRow **<-** nrow(inactiveTeams)*# Add as many empty rows to inactiveTeams as teams not making the
# playoffs.*
inactiveTeams[currentEndRow**+**seq(numDidntMakePlayoffs),] **<-** **NA***# Teams without playoff data do not have rows for that game type.
# I'm going to add the proper number of losing records to the
# dataframe to account for the missing rows for the playoffs.*
inactiveTeams[currentEndRow**+**seq(numDidntMakePlayoffs),
"recordType"] **<-** "Losing Record"*# To make the table work, I need to make the gameType of these rows
# "Playoffs".*
inactiveTeams[currentEndRow**+**seq(numDidntMakePlayoffs),
"gameType"] **<-** "Playoffs"*# Display a table of the game types by record types for inactive
# teams.*
knitr**::**kable(
table(inactiveTeams**$**gameType,
inactiveTeams**$**recordType),
caption**=**paste("Counts of Franchise Record Types by ",
"Game Type for Inactive Teams"))
非活跃球队按游戏类型统计的特许经营记录类型(图片由作者提供)
现在,我将在数据集中保留非活动的和活动的团队。现在让我们根据游戏类型得到一个胜率的数字总结。
*# Create a table of summary stats for win percentage by game type.*
winPercSumm **<-** teamTotalStats **%>%**
*# Select the gameType and winPercentage columns.*
select(gameType, winPercentage) **%>%**
*# Group by game type.*
group_by(gameType) **%>%**
*# Get summary statistics for winPercentage.*
summarize("Min." **=** **min**(winPercentage),
"1st Quartile" **=** quantile(winPercentage, 0.25,
na.rm**=TRUE**),
"Median" **=** quantile(winPercentage, 0.5, na.rm**=TRUE**),
"Mean" **=** mean(winPercentage, na.rm**=TRUE**),
"3rd Quartile" **=** quantile(winPercentage, 0.75,
na.rm**=TRUE**),
"Max" **=** **max**(winPercentage),
"Std. Dev." **=** sd(winPercentage, na.rm**=TRUE**)
)*# Display a table of the summary stats.*
knitr**::**kable(winPercSumm,
caption**=paste(**"Summary Statistics for Win Percentage ",
"by Game Type"),
digits**=**2)
按游戏类型统计的获胜百分比摘要(按作者分类的图片)
根据统计数据,分布看起来没有太大的不同,尽管季后赛的胜率可能会更加多变。至少有一支球队从未赢得过季后赛。让我们用箱线图来形象化这些分布。
*# Make a box plot of franchise win percentage by game type.*
plot4 **<-** ggplot(teamTotalStats,
aes(gameType,
winPercentage,
color**=**gameType)) **+**
*# Add the box plot layer.*
geom_boxplot() **+**
*# Jitter the points to add a little more info to the boxplot.*
geom_jitter() **+**
*# Add labels to the axes.*
scale_x_discrete("Game Type") **+**
scale_y_continuous("Win Percentage") **+**
*# Add a title.*
ggtitle("Franchise Win Percentage by Game Type") **+**
*# Remove the legend because it isn't needed.*
theme(legend.position**=**"none")*# Display the plot.*
plot4
作者图片
季后赛和常规赛的胜率差距肯定是存在的。即使他们的趋势接近,常规赛的胜率分布也更加紧密。这可能是因为常规赛比季后赛多。
现在让我们来看一个按比赛类型划分的每场比赛罚分钟数的数字总结。季后赛似乎有更高的中心倾向,在每场比赛的罚球时间上更易变。
*# Create a table of summary stats for penalty minutes per game by
# game type.*
penMinSumm **<-** teamTotalStats **%>%**
*# Select the gameType and penaltyMinutesPerGame columns.*
select(gameType, penaltyMinutesPerGame) **%>%**
*# Group by game type.*
group_by(gameType) **%>%**
*# Get summary statistics for penaltyMinutesPerGame.*
summarize("Min." **=** **min**(penaltyMinutesPerGame),
"1st Quartile" **=** quantile(penaltyMinutesPerGame, 0.25),
"Median" **=** quantile(penaltyMinutesPerGame, 0.5),
"Mean" **=** mean(penaltyMinutesPerGame),
"3rd Quartile" **=** quantile(penaltyMinutesPerGame, 0.75),
"Max" **=** **max**(penaltyMinutesPerGame),
"Std. Dev." **=** sd(penaltyMinutesPerGame)
)*# Display a table of the summary stats.*
knitr**::**kable(penMinSumm,
caption**=**paste("Summary Statistics for Penalty ",
"Minutes per Game by Game Type"),
digits**=**2)
按比赛类型统计每场比赛的罚分时间(图片由作者提供)
下面的柱状图提供了一种替代箱线图的方法,用于可视化每场比赛的罚分钟数分布。很明显,每场季后赛的罚分钟数远远超过了常规赛。
*# Make a histogram of penalty minutes per game by game type.*
plot5 **<-** ggplot(teamTotalStats,aes(penaltyMinutesPerGame,
y**=**..density..,
fill**=**gameType)) **+**
*# Add a semi-transparent histogram with 10 bins for regular season
# games.*
geom_histogram(data**=**subset(teamTotalStats,
gameType **==** 'Regular Season'),
bins**=**10, alpha **=** 0.5) **+**
*# Add a semi-transparent histogram with 10 bins for playoff games.*
geom_histogram(data**=**subset(teamTotalStats,gameType **==** 'Playoffs'),
bins**=**10, alpha **=** 0.5) **+**
*# Add a better legend label.*
guides(fill**=**guide_legend(title**=**"Game Type")) **+**
*# Add labels to the axes.*
scale_x_continuous("Penalty Minutes per Game") **+**
scale_y_continuous("Density") **+**
*# Add a title.*
ggtitle("Histogram of Penalty Minutes per Game by Game Type")*# Display the plot.*
plot5
作者图片
我很好奇哪支球队每场比赛在禁区里呆的时间最长。我筛选了现役球队和常规赛比赛,并为每支球队和他们每场比赛的罚球时间制作了一个条形图。我将条形图中的条形从表现最差到表现最好进行了排序。
*# Create a column with the triCode for each team ordered by most
# penalty time per game to least.*
mostPenaltyMinsRegSeason **<-** teamTotalStats **%>%**
*# Filter for active teams and regular season games.*
filter(**is.na**(lastSeasonId) **&** (gameTypeId **==** 2)) **%>%**
*# Sort from most penalty minutes per game to the least.*
arrange(desc(penaltyMinutesPerGame)) **%>%**
*# select the triCode column.*
select(triCode)*# Create a bar chart for the penalty mins per regular season game by
# active teams.*
plot6 **<-** teamTotalStats **%>%**
*# Filter for active teams and their regular season stats.*
filter(**is.na**(lastSeasonId) **&** (gameTypeId **==** 2)) **%>%**
*# Create a column that is a sorted factor of triCode.*
mutate(sortedTriCode **=** factor(
triCode, levels**=**mostPenaltyMinsRegSeason[["triCode"]],
ordered**=TRUE**)) **%>%**
*# Create a bar chart with a fill gradient for
# penaltyMinutesPerGame.*
ggplot(aes(sortedTriCode, penaltyMinutesPerGame,
fill**=**penaltyMinutesPerGame)) **+**
geom_col() **+**
*# Rotate the x-axis labls 90 degrees and remove the legend.*
theme(axis.text.x**=**element_text(angle**=**90), legend.position**=**"none")**+**
*# Change the fill gradient to go from blue to red.*
scale_fill_gradient(low**=**"blue", high**=**"red") **+**
*# Set the axes labels.*
scale_x_discrete("Team") **+**
scale_y_continuous("Penalty Minutes per Game") **+**
*# Add a title.*
ggtitle("Penalty Minutes per Regular Season Game by Team")*# Display the plot.*
plot6
作者图片
看起来费城飞人队和费城体育迷一样吵闹。我又制作了同样的柱状图,但这次是为了季后赛。我保留了常规赛最粗暴的顺序。如果常规赛和季后赛的点球有相关性,我们预计顺序不会有太大变化。
*# Create a bar chart for the penalty mins per playoff game by active*
*# teams.*
plot7 **<-** teamTotalStats **%>%**
*# Filter for active teams and their playoff stats.*
filter(**is.na**(lastSeasonId) **&** (gameTypeId **==** 3)) **%>%**
*# Create a column that is a sorted factor of triCode.*
mutate(sortedTriCode **=** factor(
triCode,
levels**=**mostPenaltyMinsRegSeason[["triCode"]], ordered**=TRUE**))**%>%**
*# Create a bar chart with a fill gradient for
# penaltyMinutesPerGame.*
ggplot(aes(sortedTriCode, penaltyMinutesPerGame,
fill**=**penaltyMinutesPerGame)) **+**
geom_col() **+**
*# Rotate the x-axis labls 90 degrees and remove the legend.*
theme(axis.text.x**=**element_text(angle**=**90), legend.position**=**"none")**+**
*# Change the fill gradient to go from blue to red.*
scale_fill_gradient(low**=**"blue", high**=**"red") **+**
*# Set the axes labels.*
scale_x_discrete("Team") **+**
scale_y_continuous("Penalty Minutes per Game") **+**
*# Add a title.*
ggtitle("Penalty Minutes per Playoff Game by Team")*# Display the plot.*
plot7
作者图片
顺序变化不大,所以常规赛点球时间和季后赛点球时间是有关联的。让我们来看看季后赛和常规赛每场比赛的罚球时间的散点图。
*# Create a scatter plot of playoff penalty time per game vs. regular
# season.*
plot8 **<-** teamTotalStats **%>%**
*# Filter for active teams.*
filter(**is.na**(lastSeasonId)) **%>%**
*# Select triCode, gameType, and penaltyMinutesPer Game.*
select(triCode, gameType, penaltyMinutesPerGame) **%>%**
*# Spread penaltyMinutesPerGame by gameType.*
spread(gameType, penaltyMinutesPerGame) **%>%**
*# Create a scatter plot with a regression line.*
ggplot(aes(`Regular Season`, Playoffs)) **+**
*# Add a scatter plot layer and adjust the size and opaqueness of
# points.*
geom_point(alpha**=**0.75, color**=**"blue") **+**
*# Add a red regression line.*
geom_smooth(method**=**lm, formula**=**y**~**x, color**=**"red") **+**
*# Set the axes labels.*
scale_x_continuous("Regular Season Penalty Min. per Game") **+**
scale_y_continuous("Playoffs Penalty Min. per Game") **+**
*# Add a title.*
ggtitle(paste("Playoff vs. Regular Season Penalty Min. Per ",
"Game (Active Teams)"))*# Display the plot.*
plot8
作者图片
虽然正相关并不十分令人惊讶,但我没想到相关性会如此紧密。
我想知道每场比赛的罚分时间和胜率有什么关系。让我们来了解一下!
我创建了一个散点图,根据游戏类型来显示每场比赛的胜率和罚分。我尝试了一下回归线。我首先从一条黄土回归线开始,看到了一个倒置的 U 型线。我决定用一条二次回归线来绘制它们,以使事情更顺利。
plot9 **<-** teamTotalStats **%>%**
*# Select the triCode, gameType, penaltyMinutesPerGame, and
# winPercentage columns.*
select(triCode, gameType, penaltyMinutesPerGame, winPercentage)**%>%**
*# Create a scatter plot of winPercentage vs.
# penaltyMinutesPerGame, coloring by game type.*
ggplot(aes(penaltyMinutesPerGame, winPercentage, color**=**gameType))**+**
*# Add a scatter plot layer and adjust the size.*
geom_point(size**=**2) **+**
*# Add a quadratic regression line.*
geom_smooth(method**=**"lm", formula**=**"y~poly(x, 2)") **+**
*# Set the axes labels.*
scale_x_continuous("Penalty Minutes per Game") **+**
scale_y_continuous("Win Percentage") **+**
*# The legend isn't needed, so remove it.*
theme(legend.position**=**"none") **+**
*# Add a title*
ggtitle(paste("Win Percentage vs. Penalty Minutes per Game by ",
"Game Type")) **+**
*# Break out the plots by game type.*
facet_wrap(**~**gameType)*# Display the plot.*
plot9
作者图片
正如你所看到的,每场比赛的胜率和罚分钟数之间的关系并不完全清晰,但似乎确实存在二次关系。在常规赛中,就胜率而言,15 分钟似乎是最佳的罚球时间。对于季后赛,它似乎在 17 分钟左右。
在这一点上,相关性并不意味着因果关系是不言而喻的。有可能存在一种罚点球的策略,即使这给了对方一个强力进攻的机会,就像篮球一样。或者它可能只是一个最佳侵略性游戏风格的副产品。不管怎样,我想测试这种关系是否有统计学意义。让我们用回归更正式地测试一下二次关系。
*# Create a model regressing win percentage on penalty minutes per
# game.*
winPercMod **<-** lm(winPercentage **~** poly(penaltyMinutesPerGame, 2),
data**=**teamTotalStats)*# Get the percentage of variance explained.*
winVarExplainedPerc **<-** **round**(100*****summary(winPercMod)[[8]],1)*# Create a table of the regression coefficients.*
tidywinPercModSumm **<-** winPercMod **%>%**
*# Pass the model through the tidy() function.*
tidy()*# Rename the variables for improved printing.*
tidywinPercModSumm[1, "term"] **=** "Intercept"
tidywinPercModSumm[2, "term"] **=** "Penalty Min. per Game"
tidywinPercModSumm[3, "term"] **=** "(Penalty Min. per Game)^2"*# Pass the tidied model output to a table and format it.*
knitr**::**kable(
tidywinPercModSumm,
caption**=**paste("Coefficient summary of Win Perc. Regressed on",
"Penalty Min. per Game with Quadratic Term"),
col.names **=** **c**("Variable", "Est. Coef.", "SE", "t", "P(|t| > 0)"),
digits**=c**(0, 2, 3, 2, 3)
)
Win Perc 系数汇总。在罚分上倒退。每场比赛有二次项(图片由作者提供)
给定回归中二次项的 t 统计量,二次关系似乎是合理的。该模型解释了成功百分比中 23.8%的差异,因此在预测模型中仍有相当数量的差异需要考虑。尽管如此,这仍然是一个有趣的发现!
总结
为了总结我在这篇短文中所做的一切,我构建了与 NHL API 的一些端点进行交互的函数,检索了一些数据,并使用表格、数字摘要和数据可视化对其进行了研究。我发现了一些不足为奇的事情,比如场均投篮次数和投篮命中率与胜率相关。我还发现了一些令人惊讶的事情,即每场比赛的罚球时间与胜率成二次关系。
最重要的是,我希望我的代码有助于您与 API 进行交互!