跳至主要内容

PySpark 教程:Python 中的 RDD、DataFrame 与 Spark SQL

通过可运行的代码学习 PySpark:设置 PySpark 4.3,选择、过滤、分组与连接 DataFrame,用 Spark SQL 查询,并通过 pyspark.ml 进行客户聚类。
· 15分钟 阅读

使用 AI 探索

PySpark 是 Apache Spark 的 Python API。您在 Python 中编写 DataFrame 代码或 SQL,Spark 会将工作拆分到笔记本电脑上的每个 CPU 内核,或集群中的每台机器上。当数据集不再适合在 pandas 中舒适处理,或者相同的作业稍后需要在集群上运行且不想重写代码时,我就会用它。

在本篇 PySpark 教程中,我将安装 PySpark 4.3,创建一个 SparkSession,并解释最容易让新手困惑的两个概念:RDD 与 DataFrame,以及惰性求值。随后我们将在一个包含 541,909 行的电商数据集上,动手完成日常高频操作(select、filter、groupBy、join 和 Spark SQL),最后使用 pyspark.ml Pipeline API 构建一个 K-Means 客户细分模型。

如果您希望在教程旁配合练习,我们的 Introduction to PySpark 课程覆盖了相同内容。若想要更长的学习规划,请参阅我们的指南 How to Learn PySpark From Scratch。

要点速览

  • PySpark 是面向 Apache Spark 的 Python API,适用于超出单机内存的数据。
  • 使用 pip install "pyspark[sql]" 安装;PySpark 4.3 需要 Python 3.11+ 和 Java 17+。
  • 每个程序从 SparkSession 开始,默认 API 是 DataFrame(而非 RDD)。
  • 转换是惰性的:在调用 count() 或 show() 等动作之前不会真正执行。
  • DataFrame API 与 Spark SQL 会编译为同一执行计划,任选其一以可读性为先。
  • pyspark.ml Pipeline 可将特征处理与模型(如 K-Means)串联为一个对象。

什么是 Apache Spark?

Apache Spark 是一个开源的分布式数据处理引擎。它将数据集切分为分区,在集群中并行处理,并在步骤之间将中间结果保存在内存中,而非落盘。

这种内存优先的设计是 Spark 取代 Hadoop MapReduce 处理大多数批处理任务的主要原因(我们在 Hadoop vs Spark 中进行了比较)。Spark 源于 2009 年 UC Berkeley AMPLab,2014 年成为 Apache 顶级项目,并于 2026 年 10 月 30 日发布 4.3 版本。

一个引擎通过内置库覆盖多种工作负载:

  • Spark SQL 与 DataFrame:用于表格数据与 SQL 查询
  • Structured Streaming:用于持续到达的数据
  • MLlib(pyspark.ml):分布式机器学习
  • pandas API on Spark:在集群上运行 pandas 风格代码

关于驱动程序、集群管理器与执行器如何分工的更详细说明,请参阅我们的 Apache Spark 架构指南。

什么是 PySpark?

PySpark 是 Apache Spark 的 Python API。您的 Python 代码描述所需的转换,而运行在 Java 虚拟机(JVM)上的 Spark 引擎会在集群中规划并执行它们。您无需编写 Scala 或 Java,即可获得 Spark 的分布式执行能力。

数据科学家使用 PySpark 来清洗与聚合大型数据集,数据工程师用它构建 ETL 流水线,二者都使用 pyspark.ml 在无法放入单机的数据上训练模型。如果您多数工作目前在 pandas 中完成,DataFrame API 会让您感到很熟悉。

何时用 PySpark 而不是 pandas

当数据超出单机规模时,PySpark 的优势才会显现;在此之下,pandas 通常更快,因为它没有 JVM 启动与任务调度的开销。

特性pandasPySpark
数据规模受限于单机内存受限于集群的总内存与磁盘
执行方式立即执行,主要为单核惰性执行,可在多核或多机并行
启动开销无启动 JVM 与 SparkSession 需数秒
流式数据不支持Structured Streaming
机器学习scikit-learn 生态pyspark.ml(分布式)
容错性无;崩溃会丢失工作进度丢失的分区可根据血缘重算

我的经验法则:如果数据能在内存中游刃有余地放下,且作业在一台笔记本上即可完成,就继续用 pandas。当您开始与内存错误抗争,或该作业最终需要在集群或 Databricks 等平台上运行时,切换到 PySpark。

如何安装 PySpark

PySpark 可通过 PyPI 用一条 pip 命令安装,并打包了 Spark 运行时,因此本地开发无需单独下载 Spark 或 Hadoop。在运行前,请检查两个先决条件。

先决条件

根据官方安装指南,PySpark 4.3 需要:

  • Python 3.11 或更高版本。PySpark 4.3 放弃了对 Python 3.10 的支持;如果需要升级,请参阅我们的 Python 环境设置指南。
  • Java 17 或更高版本(Spark 4.3 可运行在 Java 17、21 与 25 上),并将 JAVA_HOME 环境变量指向该安装路径。我们的 Java 安装指南涵盖各操作系统。

可在终端中检查二者:

python3 --version  # Python 3.11 或更高
java -version      # openjdk version "17" 或更高

如果您在 DataLab 或 Databricks 中工作,PySpark 已预装,可直接跳到数据集部分。

使用 pip 安装 PySpark

我建议使用虚拟环境,避免 PySpark 的依赖与其他项目冲突:

python3 -m venv pyspark-env
source pyspark-env/bin/activate
pip install "pyspark[sql]" "pandas<3"
python3 -c "import pyspark; print(pyspark.__version__)"
4.3.0

在 Windows 上,使用 python 代替 python3,并通过 pyspark-env\Scripts\activate 激活环境。[sql] 额外项会添加 pandas、PyArrow 与 NumPy,供 PySpark 在 toPandas() 与 pandas UDF 中使用。我固定 pandas<3,因为 PySpark 4.3 会提示其尚未完全支持 pandas 3.0。

两类错误占据了大多数首次运行失败的原因:

  • JAVA_GATEWAY_EXITED 或 "Unable to locate a Java Runtime":缺少 Java 或未设置 JAVA_HOME。
  • PYTHON_VERSION_MISMATCH:Spark 的工作进程拾取了与您脚本不同的 Python。将 PYSPARK_PYTHON 设置为您环境中的解释器,例如 export PYSPARK_PYTHON=$(which python)。

有关不同操作系统(含 Windows)的步骤,请参阅我们的 PySpark 安装指南。

获取数据集

示例使用的是一个来自英国线上零售商的 541,909 条交易的电商数据集,覆盖 2010 年 12 月至 2011 年 12 月。每行代表一张发票中的一条商品明细,共 8 列:

  • InvoiceNo:发票号(以 "C" 开头表示取消)
  • StockCode 与 Description:产品编码与名称
  • Quantity 与 UnitPrice:购买数量与单价(英镑)
  • InvoiceDate:购买的日期与时间
  • CustomerID:客户标识(部分行缺失)
  • Country:客户所在国家

您可以从 DataLab 数据集页面下载为 datacamp_ecommerce.csv。该数据来自 UCI 机器学习库的 Online Retail 数据集,因此也可以从公开源重建同一文件(需要 pip install openpyxl):

import io
import urllib.request
import zipfile

import pandas as pd

url = "https://archive.ics.uci.edu/static/public/352/online+retail.zip"
with urllib.request.urlopen(url) as response:
    archive = zipfile.ZipFile(io.BytesIO(response.read()))

retail = pd.read_excel(
    archive.open("Online Retail.xlsx"),
    dtype={"InvoiceNo": str, "StockCode": str, "CustomerID": "Int64"},
)
# Match the DataLab file's date format, e.g. 12/1/10 8:26
retail["InvoiceDate"] = retail["InvoiceDate"].map(
    lambda ts: f"{ts.month}/{ts.day}/{ts:%y} {ts.hour}:{ts:%M}"
)
retail.to_csv("datacamp_ecommerce.csv", index=False)

读取 23 MB 的 Excel 文件大约需要一分钟。将 CSV 保存在您将运行其余代码的文件夹中。

创建 SparkSession

SparkSession 是每个 PySpark 程序的入口:它将您的 Python 代码与 Spark 引擎连接起来,并创建 DataFrame。通常每个应用创建一个,通过构建器模式:

from pyspark.sql import SparkSession

spark = SparkSession.builder.appName("PySparkTutorial").getOrCreate()
print(spark.version)
4.3.0

getOrCreate() 会在已有会话时返回该会话,因此在笔记本中重复调用也是安全的。从普通 Python 进程启动时,Spark 以本地模式(local[*])运行,使用您机器上的每个 CPU 内核作为工作线程,并在 http://localhost:4040 提供 Spark UI 以观察作业。完成后,调用 spark.stop() 释放资源。

加载电商数据集

会话启动后,我们就可以将 CSV 读取为 DataFrame。我传入显式 schema,而不是使用 inferSchema=True,后者会额外扫描一遍整个文件来猜测列类型:

import pyspark.sql.functions as F

schema = """
    InvoiceNo STRING, StockCode STRING, Description STRING, Quantity INT,
    InvoiceDate STRING, UnitPrice DOUBLE, CustomerID INT, Country STRING
"""

orders = spark.read.csv(
    "datacamp_ecommerce.csv", header=True, schema=schema, escape='"'
)
orders = orders.withColumn("InvoiceDate", F.to_timestamp("InvoiceDate", "M/d/yy H:mm"))

orders.printSchema()
root
 |-- InvoiceNo: string (nullable = true)
 |-- StockCode: string (nullable = true)
 |-- Description: string (nullable = true)
 |-- Quantity: integer (nullable = true)
 |-- InvoiceDate: timestamp (nullable = true)
 |-- UnitPrice: double (nullable = true)
 |-- CustomerID: integer (nullable = true)
 |-- Country: string (nullable = true)

以上代码片段有两个要点:

  • escape='"' 处理包含双引号的商品名,例如 RECORD FRAME 7"" SINGLE SIZE。Spark 的默认转义字符是反斜杠。
  • 日期模式 M/d/yy H:mm 匹配如 12/1/10 8:26 这样的值。自 Spark 4.0 起,默认启用 ANSI SQL 模式,因此 to_timestamp() 在遇到与模式不匹配的值时会抛出 CANNOT_PARSE_TIMESTAMP 错误。如果您更希望坏行得到 NULL,可使用 F.try_to_timestamp()。

查看前几行并计数:

orders.show(5, truncate=False)
print(orders.count())
+---------+---------+-----------------------------------+--------+-------------------+---------+----------+--------------+
|InvoiceNo|StockCode|Description                        |Quantity|InvoiceDate        |UnitPrice|CustomerID|Country       |
+---------+---------+-----------------------------------+--------+-------------------+---------+----------+--------------+
|536365   |85123A   |WHITE HANGING HEART T-LIGHT HOLDER |6       |2010-12-01 08:26:00|2.55     |17850     |United Kingdom|
|536365   |71053    |WHITE METAL LANTERN                |6       |2010-12-01 08:26:00|3.39     |17850     |United Kingdom|
|536365   |84406B   |CREAM CUPID HEARTS COAT HANGER     |8       |2010-12-01 08:26:00|2.75     |17850     |United Kingdom|
|536365   |84029G   |KNITTED UNION FLAG HOT WATER BOTTLE|6       |2010-12-01 08:26:00|3.39     |17850     |United Kingdom|
|536365   |84029E   |RED WOOLLY HOTTIE WHITE HEART.     |6       |2010-12-01 08:26:00|3.39     |17850     |United Kingdom|
+---------+---------+-----------------------------------+--------+-------------------+---------+----------+--------------+
only showing top 5 rows

541909

关于更多读取选项,如多文件、分隔符与损坏记录,请参阅我们的 PySpark 读取 CSV 教程。

RDD 与 DataFrame

RDD 与 DataFrame 是 PySpark 表示分布式数据的两种方式,而对于几乎所有新代码,DataFrame 是正确选择。

抽象是什么适用场景
RDD(弹性分布式数据集)低层级、不可变的任意 Python 对象集合,按分区切分非结构化数据,或当您需要对分区进行精细控制时
DataFrame具有命名与类型化列的分布式表,类似 pandas DataFrame分析、SQL 查询、ETL 与机器学习

下面是同一聚合(每个国家的总数量)用两种方式编写:

sales = [("United Kingdom", 6), ("France", 24), ("United Kingdom", 8)]

# RDD:使用 Python 函数编写逻辑
rdd = spark.sparkContext.parallelize(sales)
print(rdd.reduceByKey(lambda a, b: a + b).collect())

# DataFrame:声明想要的结果,Spark 规划计算方式
sales_df = spark.createDataFrame(sales, ["country", "quantity"])
sales_df.groupBy("country").sum("quantity").show()
[('France', 24), ('United Kingdom', 14)]
+--------------+-------------+
|       country|sum(quantity)|
+--------------+-------------+
|United Kingdom|           14|
|        France|           24|
+--------------+-------------+

RDD 版本会在 Python 工作进程中运行您的 lambda。DataFrame 版本会经过 Spark 的 Catalyst 优化器,它会将查询改写为高效的计划并在 JVM 内执行,因此通常无需调优就更快。本文其余部分将使用 DataFrame。

惰性求值:转换与动作

惰性求值意味着 Spark 在您调用转换时并不会执行;它会将步骤记录到计划中,直到某个动作请求结果时才运行整个计划。

 转换(Transformations)动作(Actions)
作用描述一个新的 DataFrame返回结果或写出数据
何时运行自身不会运行立即运行,触发所有待处理的转换
示例select()、filter()、withColumn()、groupBy()、join()show()、count()、collect()、toPandas()、write

下面的过滤会立即返回,即使数据集有超过 50 万行,因为此时尚未读取任何内容。真正启动作业的是 count():

uk_orders = orders.filter(F.col("Country") == "United Kingdom")  # 转换:尚未启动作业
print(uk_orders.count())                                          # 动作:Spark 现在开始读文件
495478

等到动作再执行让 Spark 能一次性优化整条链。explain() 会打印它选择的物理计划(此处做了裁剪):

uk_orders.explain()
== Physical Plan ==
*(1) Project [InvoiceNo#0, StockCode#1, ...]
+- *(1) Filter (isnotnull(Country#7) AND (Country#7 = United Kingdom))
   +- FileScan csv [...] PushedFilters: [IsNotNull(Country), EqualTo(Country,United Kingdom)], ...

PushedFilters 表示 Spark 将国家过滤下推到文件扫描阶段,因此在读取 CSV 时就会丢弃其他国家的行。一个实际影响是:转换中的数据错误(例如在 ANSI 模式下日期解析失败)只会在后续某个动作运行时才暴露,通常会相隔几行代码。

在有动作请求结果前,Spark 不会执行任何操作

常见 DataFrame 操作

多数 PySpark 代码由五个 DataFrame 操作链式组成:select()、filter()、withColumn()、groupBy() 与 join()。每个操作都会返回一个新的 DataFrame,因此可以任意顺序串联。

使用 select() 选择列

select() 返回仅包含所命名列的 DataFrame:

orders.select("InvoiceNo", "Description", "Quantity", "UnitPrice").show(3, truncate=False)
+---------+----------------------------------+--------+---------+
|InvoiceNo|Description                       |Quantity|UnitPrice|
+---------+----------------------------------+--------+---------+
|536365   |WHITE HANGING HEART T-LIGHT HOLDER|6       |2.55     |
|536365   |WHITE METAL LANTERN               |6       |3.39     |
|536365   |CREAM CUPID HEARTS COAT HANGER    |8       |2.75     |
+---------+----------------------------------+--------+---------+
only showing top 3 rows

使用 filter() 过滤行

filter() 保留满足条件的行。该数据集混合了销售与取消(发票号以 "C" 开头)、负数量以及缺少客户的行,因此我只保留有效销售:

sales_only = orders.filter(
    (F.col("Quantity") > 0)
    & (F.col("UnitPrice") > 0)
    & F.col("CustomerID").isNotNull()
    & ~F.col("InvoiceNo").startswith("C")
)
print(sales_only.count())
397884

用 &(与)、|(或)与 ~(非)组合条件,并为每个条件加括号,因为 Python 的运算符优先级会让 & 先于 > 计算。更多模式请参阅我们的 PySpark 过滤教程。

使用 withColumn() 添加列

withColumn() 会添加一列,或替换同名列。下面计算每条明细的金额:

sales_only = sales_only.withColumn("TotalAmount", F.round(F.col("Quantity") * F.col("UnitPrice"), 2))
sales_only.select("InvoiceNo", "Quantity", "UnitPrice", "TotalAmount").show(3)
+---------+--------+---------+-----------+
|InvoiceNo|Quantity|UnitPrice|TotalAmount|
+---------+--------+---------+-----------+
|   536365|       6|     2.55|       15.3|
|   536365|       6|     3.39|      20.34|
|   536365|       8|     2.75|       22.0|
+---------+--------+---------+-----------+
only showing top 3 rows

关于用 F.when() 创建条件列,请参阅我们的 PySpark withColumn() 教程。

用 groupBy() 与 agg() 聚合

groupBy() 按一个或多个列分组,agg() 则对每个组计算一个或多个汇总。哪些国家带来最多收入?

country_revenue = (
    sales_only.groupBy("Country")
    .agg(
        F.count_distinct("CustomerID").alias("customers"),
        F.round(F.sum("TotalAmount"), 2).alias("revenue"),
    )
    .orderBy(F.desc("revenue"))
)
country_revenue.show(5)
+--------------+---------+----------+
|       Country|customers|   revenue|
+--------------+---------+----------+
|United Kingdom|     3920|7308391.55|
|   Netherlands|        9| 285446.34|
|          EIRE|        3|  265545.9|
|       Germany|       94| 228867.14|
|        France|       87| 209024.05|
+--------------+---------+----------+
only showing top 5 rows

如预期,英国(UK)占据主导。更有意思的是荷兰与爱尔兰(标注为 EIRE):9 与 3 位客户的消费超过了德国的 94 位,暗示批发客户的存在。

注意我将函数模块以 F 导入,而非使用 from pyspark.sql.functions import *,后者会遮蔽 Python 内置的 sum()、min() 与 max()。自 PySpark 4.0 起,通配导入也不再暴露旧的驼峰命名 countDistinct。

更多聚合内容请参阅我们的 PySpark groupBy 教程。

使用 join() 连接 DataFrame

join() 基于共享键合并两个 DataFrame。为了为每个国家标注区域,我将连接一个故意漏掉 Australia 的小型对照表:

regions = spark.createDataFrame(
    [("United Kingdom", "UK"), ("Netherlands", "EU"), ("EIRE", "EU"),
     ("Germany", "EU"), ("France", "EU")],
    ["Country", "region"],
)
country_revenue.join(regions, on="Country", how="left").orderBy(F.desc("revenue")).show(6)
+--------------+---------+----------+------+
|       Country|customers|   revenue|region|
+--------------+---------+----------+------+
|United Kingdom|     3920|7308391.55|    UK|
|   Netherlands|        9| 285446.34|    EU|
|          EIRE|        3|  265545.9|    EU|
|       Germany|       94| 228867.14|    EU|
|        France|       87| 209024.05|    EU|
|     Australia|        9| 138521.31|  NULL|
+--------------+---------+----------+------+
only showing top 6 rows

左连接会保留左侧 DataFrame 的所有行,并用 NULL 填充缺失匹配,因此 Australia 仍在结果中。内连接(how="inner",默认)则会将其丢弃。我们的 PySpark 连接教程比较了所有连接类型及其性能。

PySpark 中的 Spark SQL

通过将 DataFrame 注册为临时视图,Spark SQL 允许您用标准 SQL 查询它。下面的查询复现了 groupBy 小节中的国家收入表:

sales_only.createOrReplaceTempView("sales")

spark.sql("""
    SELECT Country,
           COUNT(DISTINCT CustomerID) AS customers,
           ROUND(SUM(TotalAmount), 2) AS revenue
    FROM sales
    GROUP BY Country
    ORDER BY revenue DESC
    LIMIT 5
""").show()
+--------------+---------+----------+
|       Country|customers|   revenue|
+--------------+---------+----------+
|United Kingdom|     3920|7308391.55|
|   Netherlands|        9| 285446.34|
|          EIRE|        3|  265545.9|
|       Germany|       94| 228867.14|
|        France|       87| 209024.05|
+--------------+---------+----------+

两种写法都会编译为相同的优化计划,因此没有谁更快。当我写较长的聚合且读者是分析师时,我更偏向 SQL;当我需要在查询前后配合 Python 逻辑(例如在循环中构建过滤条件)时,我会使用 DataFrame API。spark.sql() 返回的是 DataFrame,因此可以自由混用二者。

临时视图会在 SparkSession 停止时失效。

PySpark 机器学习:K-Means 示例

PySpark 的 pyspark.ml 模块基于 DataFrame 训练模型,而 Pipeline 则将特征处理与模型串联为一个可拟合与复用的对象。为演示这一点,我将基于最近一次购买、购买频次与消费金额(RFM)进行 K-Means 聚类,对零售商的客户进行细分,这是标准的营销细分方法:

  • Recency(最近度):距离客户上次购买的天数
  • Frequency(频次):不同发票的数量
  • Monetary(金额):总消费金额

用 groupBy() 构建 RFM 特征

在清洗后的 sales_only DataFrame 上做一次 groupBy() 就能得到这三个特征。最近度以数据中最后一张发票的次日为基准计算:

snapshot = sales_only.agg(F.date_add(F.max("InvoiceDate"), 1)).first()[0]

rfm = sales_only.groupBy("CustomerID").agg(
    F.datediff(F.lit(snapshot), F.max("InvoiceDate")).alias("recency"),
    F.count_distinct("InvoiceNo").alias("frequency"),
    F.round(F.sum("TotalAmount"), 2).alias("monetary"),
)
rfm.describe("recency", "frequency", "monetary").show()
+-------+------------------+-----------------+------------------+
|summary|           recency|        frequency|          monetary|
+-------+------------------+-----------------+------------------+
|  count|              4338|             4338|              4338|
|   mean| 93.05947441217151|4.272014753342554|2054.2664591977896|
| stddev|100.01226424171826|7.697997826095038| 8989.230441421178|
|    min|                 1|                1|              3.75|
|    max|               374|              209|         280206.02|
+-------+------------------+-----------------+------------------+

看看 monetary 列:平均约 £2,054,标准差是其四倍以上,最大值超过 £280,000。K-Means 使用欧氏距离,少数大额客户会主导聚类。先对各特征取对数能压缩这种长尾。

在 Pipeline 中缩放与聚类

该 Pipeline 包含 3 个阶段:VectorAssembler 将特征打包到 pyspark.ml 模型所需的单个向量列中,StandardScaler 将它们置于同一量纲,KMeans 进行聚类。我对 k = 2 到 6 进行拟合,并用轮廓系数打分,取值范围为 -1 到 1,越高表示簇分离越好:

from pyspark.ml import Pipeline
from pyspark.ml.clustering import KMeans
from pyspark.ml.evaluation import ClusteringEvaluator
from pyspark.ml.feature import StandardScaler, VectorAssembler

rfm_log = rfm.select(
    "CustomerID",
    "recency", "frequency", "monetary",
    F.log1p("recency").alias("log_recency"),
    F.log1p("frequency").alias("log_frequency"),
    F.log1p("monetary").alias("log_monetary"),
).cache()

assembler = VectorAssembler(
    inputCols=["log_recency", "log_frequency", "log_monetary"], outputCol="features_raw"
)
scaler = StandardScaler(
    inputCol="features_raw", outputCol="features", withMean=True, withStd=True
)
evaluator = ClusteringEvaluator(featuresCol="features", metricName="silhouette")

for k in range(2, 7):
    kmeans = KMeans(featuresCol="features", k=k, seed=42)
    model = Pipeline(stages=[assembler, scaler, kmeans]).fit(rfm_log)
    score = evaluator.evaluate(model.transform(rfm_log))
    print(f"k={k}  silhouette={score:.3f}")
k=2  silhouette=0.624
k=3  silhouette=0.502
k=4  silhouette=0.502
k=5  silhouette=0.478
k=6  silhouette=0.470

.cache() 将 rfm_log 保存在内存中,因为循环中的每次拟合与评估都会再次读取它;否则 Spark 会在每次拟合时从 CSV 重新计算 RFM 表。

选择 k 并解读细分

轮廓系数在 k = 2 时达到峰值,但 2 个细分主要是将活跃客户与流失客户分开,这对市场团队的可操作性较低。k = 4 与 k = 3 得分相同,但它将活跃客户进一步拆分为大额客户与新客户,因此我选择 k = 4,并对各簇做画像:

kmeans = KMeans(featuresCol="features", k=4, seed=42)
model = Pipeline(stages=[assembler, scaler, kmeans]).fit(rfm_log)
segments = model.transform(rfm_log)

(segments.groupBy("prediction")
    .agg(
        F.count("*").alias("customers"),
        F.round(F.avg("recency"), 1).alias("avg_recency"),
        F.round(F.avg("frequency"), 1).alias("avg_frequency"),
        F.round(F.avg("monetary"), 1).alias("avg_monetary"),
    )
    .orderBy("prediction")
    .show())
+----------+---------+-----------+-------------+------------+
|prediction|customers|avg_recency|avg_frequency|avg_monetary|
+----------+---------+-----------+-------------+------------+
|         0|     1598|      181.2|          1.3|       326.2|
|         1|      720|       13.2|         13.7|      8072.7|
|         2|     1162|       77.1|          3.9|      1765.2|
|         3|      858|       17.4|          2.3|       613.7|
+----------+---------+-----------+-------------+------------+

四个细分的解读很清晰:

  • 簇 1,最佳客户(720):平均在近两周内有购买,约 14 笔订单,人均消费超过 £8,000。留存与忠诚度优惠应投放于此。
  • 簇 3,近期但轻度(858):近三周内活跃,但仅约 2 笔订单。许多可能是值得培育到第 2、3 单的新客户。
  • 簇 2,冷却中(1,162):中等消费,但距离上次购买约 11 周。挽回活动在这里成功概率最高。
  • 簇 0,流失的一次性购买者(1,598):多为单笔订单,约 £326,约 6 个月前。体量最大、价值最低。

720 位最佳客户非常突出;1,598 位是一锤子买卖的流失客户

簇号只是任意标签,换一个随机种子可能会重新编号,因此在命名之前务必先画像。若想了解 K-Means 如何选择中心点,请参阅我们的 k-means 聚类入门;pyspark.ml API 参考列出了所有模型与转换器。我们的 Machine Learning with PySpark 课程涵盖分类、回归及基于 Pipeline 的模型调优。

结语

PySpark 的核心要点是:用 SparkSession 起步,几乎所有工作都用 DataFrame,转换是惰性的,只有在动作需要结果时才会运行,且每个查询既可用 Python 也可用 SQL 编写。本文所有内容都在笔记本本地模式下运行,同样的代码无需修改即可在集群上运行。

一个 541,909 行的文件并不需要集群,pandas 也能胜任。这是刻意为之,因为在可检查的数据上更容易养成处理大数据时重要的习惯(显式 schema、尽早过滤、缓存复用的数据、查看 explain())。

接下来,您可根据目标选择这些最佳路径:

如果您想更进一步,推荐我们的 Big Data with PySpark 学习路径,依次覆盖数据管理、特征工程、机器学习流水线与推荐系统。团队用户可选 DataCamp for Business 获取数据工程岗位的学习路径。

PySpark 常见问答

学习 PySpark 的先决条件是什么?

PySpark 4.3 需要 Python 3.11 或更高版本与 Java 17 或更高版本(Spark 4.3 可运行在 Java 17、21 与 25 上),并将 JAVA_HOME 指向您的 Java 安装路径。您应熟悉基础 Python;有 pandas DataFrame 经验更佳,因为 PySpark 的 DataFrame API 与其理念相近。您不需要具备分布式系统的先验经验。

PySpark 比 pandas 更快吗?

对于小数据,并不更快。pandas 在进程内运行且无启动开销,而 PySpark 需要数秒启动 JVM 并调度任务,因此当数据能舒适地放入内存时,pandas 通常更占优。一旦工作负载超出单机内存或 CPU,PySpark 就更快,因为它可在多核或集群节点之间并行处理分区。

PySpark 中的 RDD 与 DataFrame 有何区别?

RDD(弹性分布式数据集)是 PySpark 的低层结构:可分布的任意 Python 对象集合,您用自己的函数处理。DataFrame 则将数据组织为具名、类型化的列,类似 pandas DataFrame,Spark 的 Catalyst 优化器会将 DataFrame 查询转成高效的执行计划。新代码几乎都应使用 DataFrame,仅在处理非结构化数据或需要精细分区控制时使用 RDD。

如何在 Jupyter Notebook 中运行 PySpark?

安装 Java 17 或更高版本,然后在笔记本对应的环境中运行 pip install "pyspark[sql]"。在首个单元格中创建 SparkSession:

from pyspark.sql import SparkSession
spark = SparkSession.builder.appName("MyNotebook").getOrCreate()

如果您希望跳过本地安装,DataLab 与 Databricks 笔记本已预装 PySpark。

何时应使用 PySpark 而不是 pandas?

当您的数据无法放入单机内存、作业需要在集群或 Databricks 等平台上运行,或当您在构建会超出笔记本规模的流水线时,请使用 PySpark。对于能放入内存的探索性分析,pandas 更简单且即开即用。在本地编写的 PySpark 代码可不加修改地在集群上运行,因此若规模扩张在所难免,起步就用 PySpark 是合理的。

PySpark 的惰性求值是什么?

惰性求值意味着在调用 filter() 或 withColumn() 等转换时,PySpark 并不会立即执行。它会将这些步骤记录到计划中,只有当某个动作(如 count()、show() 或 write)需要结果时才会运行该计划。等待执行让 Spark 能优化整条链,例如将过滤下推到文件扫描阶段。

如何在 PySpark 中运行 SQL 查询?

用 df.createOrReplaceTempView("sales") 将 DataFrame 注册为临时视图,然后通过 spark.sql("SELECT ... FROM sales") 查询。结果是常规 DataFrame,因此您可以自由混用 SQL 与 DataFrame 代码。二者会编译为同一优化计划,因此没有谁更快。

运行 PySpark 需要 Hadoop 吗?

不需要。pip install pyspark 已包含 Spark 运行时,可在本地模式下运行,无需 Hadoop。只有在从 HDFS 读取或在 YARN 集群上运行 Spark 时才需要 Hadoop 组件。

Natassha Selvaraj

作者

Natassha Selvaraj

Natassha 是一名在数据科学与营销交汇处工作的数据顾问。她相信,明智地使用数据,能够为个人和组织带来巨大的增长。作为一名自学成才的数据从业者,Natassha 热衷于撰写文章,帮助有志于数据科学的人士进入该行业。她在个人博客以及外部平台发表的文章,平均每月获得 20 万次浏览。

主题