先说结论:这个项目真正的难点不在“预测”,而在把 Hadoop 和 PySpark 这套分布式处理逻辑,跟 Prophet 这种以 pandas 数据框为核心的单机模型舒服地拼在一起。我前后折腾了大概两周,踩的坑比写的代码还多,今天把完整的思路、代码片段和排错过程都整理出来,希望能帮后来的人少走点弯路。
这个系统适合谁?如果你是第一次接触“大数据 + 时间序列预测”这种组合,或者已经单机跑通过 Prophet 但不知道怎么往 Spark 上迁,这篇内容可以给你一条完整的参考路径。文章里涉及的操作我都以模拟项目X(一套城市空气质量历史数据)为例,所有字段和参数都做了脱敏处理,但逻辑完全可复现。
1. 为什么非要把 Hadoop、PySpark 和 Prophet 攒在一起
1.1 项目背景:单机脚本撑不住之后
最初这个需求特别朴素:把某城市过去五年的空气质量监测数据拿出来,预测未来七天的 PM2.5。数据量听起来也不夸张,一年大概几十万条记录,五年也就两三百万行。用 pandas 读 CSV 再喂给 Prophet,理论上完全扛得住。但实际一跑就发现问题了——原始数据散落在十几个不同来源里,有的是按小时上报的站点传感器读数,有的是天气站下载的气象表,还有一部分是第三方接口导出的日志文件,格式、字段名、时间粒度全都不一样。
我第一版写了个纯 pandas 脚本,处理流程就是“读一个文件 -> 清洗一个文件 -> 合并到一个 DataFrame”,跑一次全量清洗大概要四十多分钟。而且每次新增数据源或者调整过滤逻辑,就得从头再来一遍。最难受的是内存:某几次加载全量历史数据时直接把开发机的 16G 内存吃满,连 IDE 都开始卡。到这一步我意识到,问题不是 Prophet 太慢,而是“数据准备阶段”缺少一个能横向扩展的清洗层。
这时候引入 Hadoop 和 PySpark 的目的就清晰了:HDFS 负责把分散的文件统一收拢存储,PySpark 负责把清洗和特征工程变成分布式任务。原始文件丢到 HDFS 上,spark 作业并行读、并行洗、并行算,跑一次全量清洗从四十多分钟降到五六分钟,而且以后加节点就能继续扩。
1.2 技术选型的真实理由:哪些环节真的需要 Spark
很多人一听“Hadoop + Spark + 预测”,下意识觉得这是杀鸡用牛刀。我实际用下来,整套栈里的分工是这样的:
- HDFS:解决“文件散落 + 历史数据归档”问题。所有原始 CSV 统一放到 HDFS 指定目录,后续清洗任务只要能访问 HDFS 就能处理,不依赖某台机器上的本地文件。
- PySpark(清洗阶段):解决“多数据源 join 和重复清洗”问题。不同站点、不同时间粒度的数据,用 DataFrame 的
groupBy + pivot + join在分布式环境里跑,比 pandas 的merge要稳当得多,尤其是内存不再成为瓶颈。 - Prophet(预测阶段):真正跑模型的部分依然是单机。Prophet 本身没有分布式实现,它需要的输入是一张规整好的、包含
ds和y两列的 DataFrame,这个数据量在模型训练时可大可小,但常规城市级数据几十万行规模单机完全跑得动。
所以准确的讲,这是一个“分布式数据处理 + 单机模型训练”的混合架构。Spark 不负责预测,只负责把数据喂到 Prophet 嘴边的过程。明确这一点,后面所有设计都会顺很多。
1.3 总体数据流向
我最后搭出来的数据流向是这样的:
原始文件(多源 CSV/JSON) -> HDFS 落盘 -> PySpark 清洗作业 -> 特征宽表(按小时粒度) -> 导出成建模所需 CSV -> Prophet 训练与预测 -> 预测结果写入结果表 -> 可视化服务读取并展示
这个链条里,只有最后一个“可视化服务读取”用的是传统关系库或 ES,中间清洗结果我直接用了 Parquet 格式存 HDFS,建模前再导出成临时 CSV。这样做的好处是中间产物可回溯、可重跑,不会因为某一步参数调错就把源头数据污染了。
2. 环境准备:版本匹配才是第一个隐形杀手
2.1 版本组合与 Java/Spark 兼容
这个项目最大的坑在环境依赖。我一开始随手装了最新版 Spark,结果跟 Hadoop 的 RPC 协议对不上,作业提交后一直报Connection refused。后来梳理了一遍兼容矩阵,确定了一套稳定组合:
| 组件 | 版本 | 说明 |
|---|---|---|
| Java | OpenJDK 8 | Spark 3.x 对 Java 8 支持最好,Java 11 也能跑但个别扩展包有兼容问题 |
| Hadoop | 3.3.4 | 只用到 HDFS 和 YARN 基础能力 |
| Spark | 3.2.1 | 编译时对应 Hadoop 3.2 以上即可,实际测试 3.3.4 无问题 |
| Python | 3.9 | 太高的版本(比如 3.11)在某些 Prophet 依赖上编译会有麻烦 |
| Prophet | 1.1.4 | 这个版本对 pandas 1.5.x 兼容性最好 |
这里单独说一下 Python 版本。Prophet 底层依赖 cmdstanpy,新版 Stan 对编译器要求高,如果你用的是 Python 3.11 或 3.12,在部分 Linux 发行版上安装 prophet 时会卡在编译 C++ 扩展那一步。我为了省事直接把环境固定在 Python 3.9 + Prophet 1.1.4,整个安装过程一条pip install prophet就能过,不折腾。
2.2 PySpark 内存与 YARN 配置
PySpark 作业跑在 YARN 上时,最常遇到的问题是Container killed on request或GC overhead limit exceeded。这个不一定是代码写错了,更多是资源参数没配好。我的开发机是 8 核 16G,配置如下:
# spark-defaults.conf 关键参数 spark.master=yarn spark.driver.memory=2g spark.executor.instances=3 spark.executor.memory=3g spark.executor.cores=2 spark.yarn.am.memory=1g spark.sql.shuffle.partitions=50 spark.default.parallelism=50spark.sql.shuffle.partitions这个参数很容易被忽略。默认值是 200,也就是说执行 join 或 groupBy 的时候会默认生成 200 个分区。对这份数据量来说,200 个分区只会徒增调度开销,每个分区里又没多少数据,反而拖慢速度。调成 50 之后,整个清洗作业快了将近一倍。
如果你是在本地单机跑 spark-submit,不想起 YARN 集群,可以直接用spark.master=local[4],但要注意local[*]的星号有时会拿到所有核,跟其他任务抢资源。
2.3 开发模式选择:本地调试加集群提交
我的建议是分成两个模式。日常写 PySpark 清洗逻辑时,直接在 IDE 里用local[2]模式跑,配上少量采样数据做验证,这样报错时堆栈信息是全的,非常方便;确认逻辑没问题了,再把作业提交到 YARN 上跑全量数据。通过在 spark session 里加一个全局开关来控制运行模式即可:
import os RUN_MODE = os.getenv("RUN_MODE", "local") # local / cluster if RUN_MODE == "local": spark = SparkSession.builder \ .appName("AirQualityClean_Local") \ .master("local[2]") \ .config("spark.sql.shuffle.partitions", "20") \ .getOrCreate() else: spark = SparkSession.builder \ .appName("AirQualityClean_Cluster") \ .master("yarn") \ .config("spark.executor.memory", "3g") \ .config("spark.sql.shuffle.partitions", "50") \ .getOrCreate()这样最舒服的一点是:写逻辑和写配置分离。我在本地改了清洗逻辑,直接跑小数据集验证,确认无误后把同一份代码用--master yarn方式提交到集群,源代码完全不用改。
3. 数据接入与清洗:从 HDFS 原始文件到可建模宽表
3.1 数据字段与口径统一
空气质量监测的原始数据,最常见的字段大致有这些:站点编号、监测时间、PM2.5 浓度、PM10 浓度、SO2 浓度、NO2 浓度、O3 浓度、CO 浓度,外加气象侧的温度、湿度、气压、风速、降水等。不同来源的文件里,时间格式有的叫time,有的叫record_time,有的是字符串2023-01-01 08:00:00,有的是 Unix 时间戳。第一步必须统一列名和时间格式。
我统一的口径是:
- 时间列统一命名为
time,格式为yyyy-MM-dd HH:00:00,全部按整点对齐。 - 浓度单位统一为 μg/m³(CO 为 mg/m³)。
- 30 分钟内重复的记录,保留后一条。
- 空气质检数据若某小时缺失,用前后两小时均值插值,前提是连续缺失不超过 3 小时;超过 3 小时则标记为
null,在建模阶段由 Prophet 自动处理。
统一时间格式这一步,是后面所有 join 操作的基础。不同数据源的时间精度如果不一致(比如一个精确到分,一个精确到秒),直接 join 会产生大量无法匹配的行。我建议哪怕原始文件里是精确到秒的时间,也先截断成整点再合并。
3.2 读取 HDFS 原始文件的 PySpark 写法
把文件考到 HDFS 之后,读取逻辑非常简单。用 Spark 原生 CSV 读取器,加上 Schema 定义,可以避免自动推断类型带来的性能损耗:
from pyspark.sql.types import StructType, StructField from pyspark.sql.types import StringType, DoubleType, TimestampType schema = StructType([ StructField("station_id", StringType(), True), StructField("record_time", StringType(), True), StructField("pm25", DoubleType(), True), StructField("pm10", DoubleType(), True), StructField("so2", DoubleType(), True), StructField("no2", DoubleType(), True), StructField("o3", DoubleType(), True), StructField("co", DoubleType(), True), ]) df_raw = spark.read.csv( "hdfs:///user/airquality/raw/station_*.csv", header=True, schema=schema, sep="," )这里有个小细节:文件路径用通配符station_*.csv,Spark 会自动把匹配到的多个文件当作同一张表来读。如果你某个数据源的文件特别多(比如按天分文件存储),这种写法比逐个文件列出路径高效太多。
3.3 清洗逻辑:数值过滤比想象中复杂
原始数据里最坑的是异常值。空气监测仪在故障或校准时会产生一些极端值,比如 PM2.5 瞬间变成 999、-1、或者 0。直接删掉是不合适的,因为有部分 0 是真实值(雨后极洁净天气),但 999 这种一般是传感器故障。我的处理策略分两层:
- 硬过滤:浓度值小于 0 的删除;PM2.5、PM10 大于 500 的删除(极端沙尘暴除外,但本项目范围内先按此逻辑)。
- 区间校验:用 3σ 原则识别离群点,但只标记不删除,通过把离群值按前后 6 小时均值平滑处理。
这一层在 PySpark 里可以用自定义 UDF 或者窗口函数实现。推荐用窗口函数,性能比 UDF 好得多:
from pyspark.sql.window import Window from pyspark.sql.functions import avg, col, when w = Window.partitionBy("station_id").orderBy("time").rowsBetween(-3, 3) df_clean = df_raw \ .withColumn("pm25_smooth_avg", avg("pm25").over(w)) \ .withColumn("pm25", when(abs(col("pm25") - col("pm25_smooth_avg")) > 150, col("pm25_smooth_avg")) .otherwise(col("pm25")))简单说就是:把每个站点前后 3 小时(共 7 个点)的平均值算出来,如果某个时刻的原始值跟这个平均值的差超过 150,就把这个点替换成平均值。这种方法对单点突变非常有效,而且不会把真实的高浓度污染过程抹掉——因为真实污染过程是持续数小时的,不会只突变一个点。
3.4 多站点汇总与气象表合并
城市空气质量预测通常要的是“全市平均”或“重点站点平均”,而不是把每个站点单独训练一个模型。我这里用的是简单平均法:所有站点同一小时的数据求均值,得到逐小时全市均值。同时把气象数据表 join 过来。
df_station_avg = df_clean \ .groupBy("time") \ .agg( avg("pm25").alias("pm25"), avg("pm10").alias("pm10"), avg("so2").alias("so2"), avg("no2").alias("no2"), avg("o3").alias("o3"), avg("co").alias("co") ) df_weather_avg = df_weather \ .groupBy("time") \ .agg( avg("temp").alias("temp"), avg("humidity").alias("humidity"), avg("pressure").alias("pressure"), avg("wind_speed").alias("wind_speed") ) df_feature = df_station_avg.join(df_weather_avg, on="time", how="left")这里 join 用left而不是inner,是因为气象数据偶发缺失,缺失时段后续由 Prophet 内部处理或简单前向填充,直接影响不大。
3.5 导出建模数据
清洗完之后,我通常会把结果存成两份:一份是 Parquet 格式放到 HDFS 上,作为中间层可回溯;一份是导出成 CSV 放到本地,供 Prophet 读取。导出用 PySpark 的repartition(1)确保只生成一个文件,避免后续读取时多文件拼接:
df_feature.repartition(1) \ .write.mode("overwrite") \ .csv("/user/airquality/clean/air_feature_hourly.csv", header=True)然后用hadoop fs -get拉到本地即可。这一步有个经验:repartition(1)在数据量大时会有一定性能损耗,但从建模角度讲,只有一个文件会让 Prophet 侧读取简单很多,两三百 MB 的数据量完全能接受。
4. Prophet 模型构建:为什么默认参数在空气质量数据上会翻车
4.1 Prophet 适用于空气质量的原理
Prophet 的本质是“可分解加性模型”,把时间序列分解成趋势项、季节项、节假日效应项和误差项。对空气质量数据来说,这个分解天然合理:PM2.5 浓度有明显的年季周期(冬季高、夏季低)、有日内周期(早晚高峰高、午后低),还有由天气突变带来的非周期波动(冷空气过境后的扩散条件变化)。这些正好是 Prophet 的舒适区。
输入 Prophet 的数据只需要两列:
ds:时间戳,必须是 datetime 类型。y:目标值,数值类型。
其他所有特征——比如温度、湿度、风速——如果想纳入模型,需要用add_regressor加为额外回归项。
4.2 调参关键:季节模式和变点尺度
我第一版直接用默认参数跑,结果极其拉胯:预测曲线几乎是一条纯趋势线,完全没抓住周期波动,MAPE 高达 40% 以上。问题出在两个默认参数上:
seasonality_mode='additive':空气质量数据里,冬季 PM2.5 可能到 150,夏季可能只有 30,这时候季节效应大小跟数值本身的量级是成比例的,需要用乘法模式。changepoint_prior_scale=0.05:默认值偏保守,导致模型不敢在数据里识别突变点。空气质量受政策管控、产业调整、季节转换影响,平台期和后期的结构突变不少,需要适当放开。
我最后调出来的核心参数组合:
from prophet import Prophet model = Prophet( seasonality_mode="multiplicative", changepoint_prior_scale=0.1, seasonality_prior_scale=10.0, yearly_seasonality=True, weekly_seasonality=True, daily_seasonality=True, holidays_prior_scale=15.0 ) # 添加气象回归变量 model.add_regressor("temp") model.add_regressor("humidity") model.add_regressor("pressure") model.add_regressor("wind_speed") model.fit(df_train)补充说明一下seasonality_prior_scale的作用:它控制季节效应的灵活度。默认值是 10,但在我这份数据上,默认值表现出的季节分量偏“平”,提高一点能让模型更好地拟合夏季低谷和冬季峰值。这不是说越大越好,太大容易过拟合,需要配合交叉验证去选。
4.3 训练集与评估指标
选用近三年的数据做训练,近 30 天做验证,预测未来则是 7 天。评估指标用三个:
- MAE:平均绝对误差,反映整体偏差。
- RMSE:均方根误差,对大幅偏差更敏感。
- MAPE:平均绝对百分比误差,这块数据的业务上比较看重相对误差。
用 Prophet 自带的cross_validation做时间序列交叉验证:
from prophet.diagnostics import cross_validation, performance_metrics import pandas as pd df_cv = cross_validation( model, initial="730 days", period="30 days", horizon="7 days" ) df_metrics = performance_metrics(df_cv) print(df_metrics[["mae", "rmse", "mape"]].head())在我这份模拟数据上的结果大致是:MAE 在 12~15 μg/m³ 区间,MAPE 控制在 20% 上下。作为对比,纯线性回归做同样的预测 MAPE 要到 40%,随机森林效果介于两者之间但周期外推能力差不少。Prophet 的强项就在于“没有未来气象预报数据时,单靠历史周期也能给出不差的基准线”。
4.4 预测未来 7 天并输出结果表
预测未来 7 天时,需要构造一个future数据框,同时给未来 7 天的气象回归变量填入历史同期均值或简单预测值:
future = model.make_future_dataframe(periods=7, freq="H") # 未来 7 天温度用近 5 年同日同时刻平均值填充 future = future.merge(future_weather, how="left", on="ds") forecast = model.predict(future) forecast[["ds", "yhat", "yhat_lower", "yhat_upper"]].tail(24 * 7)yhat_lower和yhat_upper是置信区间,可视化时非常有用,能清楚地展示“预测值的可信范围”。如果区间宽度过大(比如超过 50 μg/m³),说明模型对该时段信心不足,业务上就要谨慎解读。
5. 预测结果可视化:不只是画线那么简单
5.1 静态图:先看趋势是否合理
预测结果出来后,第一件事是画一张静态图确认模型行为合理。Prophet 自带的 plot 方法是最快的检查手段:
fig1 = model.plot(forecast) fig2 = model.plot_components(forecast)plot_components会画出趋势项、周季节项、日季节项三个子图。这一步非常值得花时间看,它能帮你确认模型是不是学到了合理规律:日均季节项应该有早晚两个峰,周季节项如果是“周末低、工作日高”就很合理(车辆排放减少),年季节项应该是冬高夏低。
我就在这张图上发现过一个问题:日季节项在凌晨 4 点出现了一个莫名其妙的凸起,查了半天发现是某几个站点夜间的仪器零点校准导致数据系统性偏高,这属于数据问题而非模型问题。如果没有组件分解图,这种异常很难快速定位。
5.2 交互式可视化:pyecharts 是最好用的选择
静态图自用可以,但如果要给非技术背景的人看,或者部署成内部页面,推荐用 pyecharts 做交互式折线图。它渲染出的 HTML 页面无需后端服务,直接打开就能用,也方便嵌入内部系统。
from pyecharts.charts import Line from pyecharts.options import TooltipOpts, TitleOpts line = Line(init_opts={"width": "1200px", "height": "500px"}) line.add_xaxis(actual["ds"].dt.strftime("%m-%d %H:00").tolist()) line.add_yaxis( "实际值", actual["pm25"].tolist(), is_smooth=True, linestyle_opts={"width": 2}, itemstyle_opts={"color": "#333"} ) line.add_yaxis( "预测值", forecast["yhat"].tolist(), is_smooth=True, linestyle_opts={"width": 2, "type": "dashed"}, itemstyle_opts={"color": "#d14a22"} ) line.add_yaxis( "置信上界", forecast["yhat_upper"].tolist(), linestyle_opts={"type": "dotted"} ) line.add_yaxis( "置信下界", forecast["yhat_lower"].tolist(), linestyle_opts={"type": "dotted"} ) line.set_global_opts( title_opts=TitleOpts(title="未来7天 PM2.5 浓度预测"), tooltip_opts=TooltipOpts(trigger="axis") ) line.render("air_quality_forecast_7d.html")这里我的经验是把“实际值”“预测值”“置信上界”“置信下界”四条线放在同一张图里。实际值展示的是最近 48 小时的真实数据,预测值展示未来 7 天的预测,这样看图的人能很清楚看到模型从哪个位置开始切换成预测模式,也容易判断预测合理性。
5.3 大屏展示:盯住“拐点”而不是“数值”
如果要做成值班大屏,我不建议把大量图表堆在一起。真正对决策有用的信息就三块:
- 未来 7 天预测曲线(重点看趋势拐点:是持续升高还是持续下降)。
- 未来 24 小时的逐小时精度预测表(重点看红线阈值时刻)。
- 模型置信区间范围(判断预测是否稳定)。
大屏的配色也别花哨,用深色底、亮色线,信号要突出“拐点”——比如未来 48 小时出现浓度持续上升,主题色从绿色跳成橙色。这是做可视化时特别容易忽略的:线图展示的是整体走势,而业务方每天盯的是“哪天会转差”,不看绝对数值。
6. 实际运行中的坑与注意事项
6.1 PySpark 清洗与 Prophet 建模之间的数据对齐问题
这是我这次项目里最折磨人的一个问题。PySpark 清洗完导出的 CSV,时间列默认会带有+00:00或+08:00这种时区偏移后缀。Prophet 在读取这类字符串时,有时间能自动解析,有时间会报ValueError: Unable to parse。
解决办法是在 PySpark 导出前统一转成无时区的字符串格式:
from pyspark.sql.functions import date_format df_feature = df_feature.withColumn( "ds", date_format("time", "yyyy-MM-dd HH:00:00") )然后在 Prophet 读取时再明确指定解析格式:
df_model = pd.read_csv("air_feature_hourly.csv", parse_dates=["ds"])这看起来只是一个小细节,但如果你没做这个转换,等到建模阶段再排查,会浪费大量时间。数据从 Spark 到 pandas 之间的“时区漂移”和“日期格式漂移”我会优先在源头解决,绝不在下游临时补。
6.2 缺失值处理与预测置信区间膨胀
模型对连续缺失超过 3 小时的数据,在缺失时段之后往往会给出特别宽的置信区间。这一点业务上要有预期。我的做法是:模型输出结果时,同时输出当天的数据完整度(缺失小时数/24),如果数据完整度低于 70%,就在可视化页面上打一个“该日预测可靠性低”的标签。
这个设计在真实运行里救了很多次。某几次因为上游采集设备断电,当天数据完整度不到 50%,模型虽然也能给出预测,但区间宽度明显异常,明显不可信。如果没有数据完整度标签,业务方直接把预测值拿去发通报,大概率要出问题。
6.3 定时调度与模型落盘
预测系统不能每天人工跑,我最后的做法是:
- 每天 06:00 由调度器触发 PySpark 清洗作业。
- 清洗完成后触发 Prophet 训练脚本(复用历史数据 + 更新到昨天 23:00)。
- 训练完成后自动生成未来 7 天预测并输出到结果表。
- 可视化服务每天 07:00 刷新页面数据。
调度这块我用的是简单的crontab+ 日志记录。每次执行都往日志文件里追加三行:作业开始时间、关键步骤完成状态、预测结果摘要(比如“预测 7 天均值 68 μg/m³”)。这样做的好处是,第二天早上扫一眼日志就能判断昨晚的作业是否正常,不需要去看一堆运行状态页面。
6.4 模型重训练频率的取舍
Prophet 模型并不需要每次预测都重新全量训练。如果历史数据每天只增加一条,模型参数变化极小,重训性价比低。我的做法是:每周日全量重训一次,平时每天只用最新数据做预测。代码上通过判断“今天是周几”来决定是调用model.fit()还是直接加载上次的模型文件。
import pickle from datetime import datetime model_path = "/models/air_prophet_v1.pkl" if datetime.today().weekday() == 6: model = train_prophet(df_all) with open(model_path, "wb") as f: pickle.dump(model, f) else: with open(model_path, "rb") as f: model = pickle.load(f) # 每天执行预测 forecast = predict_next_7d(model, future_weather)在真实生产里,这个“周日重训、每日预测”的节奏比天天重训要稳定得多,也节约了大量无效计算时间。
最后再分享一个小经验:如果你也准备在 PySpark 清洗层和 Prophet 建模层之间传数据,建议从一开始就把“日期字符串标准化”和“缺失率监控”这两个问题纳入设计,而不是等跑出问题再补救。这两件事在架构图上不起眼,却恰恰是这类混合系统在实际运行中维护成本最高的地方。整体方案跑顺后,这套“Hadoop 管数据、Prophet 管预测、可视化管表达”的组合在类似的环保监测项目中是可以直接复用的,换一个城市、换一种污染物,只要清洗逻辑跟着调整就行。