《python+hadoop+spark+hive空气质量预测系统》是我在指导多个课程设计和实际项目之后,最想拿出来认真拆解的一个题目。
很多人一看到“空气质量预测”这几个字,第一反应是“这不就是爬点数据、跑个回归、画个图完事吗”。等真把需求摊开——历史监测数据小时级累积、气象要素要关联、要按城市和站点做多维分析、预测模型还要能定时重训,就知道单机Pandas那套根本扛不住。这个题目真正的价值在于:它把一个典型的时间序列预测问题放到了大数据技术栈里去解决,涉及数据采集、存储、清洗、特征工程、模型训练、调度上线全链路,几乎把Hadoop生态里最常用的几板斧全部过了一遍。这篇文章我会把整个系统的落地过程、架构选型依据、环境搭建踩坑、特征工程细节、模型对比结论全都写出来,给想复现这个方向的同学一条能直接走通的路线。
1. 这个项目到底在解决什么问题,为什么要上全套大数据栈
1.1 单机脚本为什么扛不住
先说一个最简单的问题:预测空气质量的PM2.5浓度,到底需不需要Hadoop、Spark、Hive这种“重武器”?
如果只是拿一个月的监测数据,几千条样本,训练一个模型,那确实不需要。Pandas读CSV,sklearn跑个随机森林,十分钟就能出结果。但真实场景不是这样的。
我做这个项目时用的数据集是全国多个城市空气质量监测站逐小时上报的数据,再加上对应时段的温度、湿度、风速、风向、气压等气象观测值。单一城市一年就是8760条记录,如果覆盖50个城市、3年历史,数据量是百万级起步。加上每次训练要构造滞后特征、滑动窗口聚合、多表关联,单机Pandas处理起来内存直接吃紧,而且每次重训都得把全量数据重新读一遍,效率极低。
更重要的是,这个系统不是一个“一次性实验”,而是一个“持续运行的预测服务”。数据每天在涨,模型每周要重训,预测结果要落库供前端查询。这时数据管理就成了核心问题:文件散落各地、格式不统一、没人知道哪份数据是最新的,单机脚本根本管不住。
所以这个题目的本质不是“用Python做个预测”,而是“用一套可靠的大数据基础设施,把数据从采集到预测的全链路管起来”。Hadoop负责海量文件存储,Hive负责把结构化数据管理起来,Spark负责高效的数据清洗和特征计算,Python负责模型训练和预测服务——各司其职。
1.2 技术选型的层次感
我在设计架构时,把整个系统分成了四层:
- 存储层:HDFS存放原始采集文件和模型产物,Hive建立外部表或内部表来管理结构化数据,按城市和日期做分区。
- 计算层:Spark SQL + DataFrame API负责数据清洗、去重、缺失值填充、特征工程,跑批任务全部在Spark上完成。
- 算法层:时间序列预测模型放在Python侧实现。为什么不用Spark MLlib?因为MLlib对时间序列的原生支持有限,ARIMA、Prophet、LSTM这些模型还是Python生态更成熟。Spark算好特征后,把特征宽表导出,Python读入训练。
- 服务层:训练好的模型保存为文件,用FastAPI或Flask封装一个预测接口,输入最近N小时的数据,输出未来24小时的逐小时浓度预测。
这种分工的核心逻辑是:让最合适的工具干最合适的活。Hadoop/Hive解决“数据放哪、怎么管”的问题,Spark解决“怎么高效算”的问题,Python解决“怎么预测”的问题。全程只用一种技术栈反而会让系统变得别扭——比如非要用Spark写深度学习,或者非要用Python处理上亿行的离线批任务,都是事倍功半。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境搭建的硬骨头:Hadoop、Spark、Hive版本兼容与伪分布式
2.1 先解决版本兼容矩阵
这个题目最容易劝退人的环节就是环境搭建。网上教程一大堆,但很多教程用的Hadoop是2.x,Spark是2.x,Hive是1.x,换个版本就各种报错。我踩了一圈之后,给的结论是下面这套版本组合最稳:
| 组件 | 推荐版本 | 说明 |
|---|---|---|
| JDK | 1.8(8u202或更早) | Hadoop 3.x对JDK 11有兼容性问题,别用太新的 |
| Hadoop | 3.2.4 | 稳定,网上资料多,伪分布式部署简单 |
| Spark | 3.3.x(自带Hive支持) | Spark 3.3编译时已经带了hive的依赖,省去很多麻烦 |
| Hive | 3.1.3 | 与Hadoop 3.x兼容良好 |
| MySQL | 5.7或8.x | 用作Hive的元数据库,比默认的Derby强太多 |
| Python | 3.8或3.9 | 后续跑pandas、sklearn、torch都方便 |
在这个组合里,最关键的一个细节是:Spark发行版默认不带Hive的元数据服务能力,但Spark SQL是可以通过Hive的metastore读取Hive表的。所以你必须保证Spark能够连接上Hive的metastore,方法是把Hive的hive-site.xml(里面配置了MySQL连接串)放进Spark的conf目录。这一点如果不做,Spark SQL建表之后就只是临时表,重启就丢了。
2.2 安装步骤里的关键操作
走一遍核心步骤:
- 配Java环境变量:改
/etc/profile,把JAVA_HOME指到JDK安装目录,这个不多说了,卡住的一般就是把JDK 17当成1.8用了。 - Hadoop伪分布式:改三个文件。
core-site.xml里设置fs.defaultFS为hdfs://localhost:9000;hdfs-site.xml里把副本数设为1,因为伪分布式只有一个DataNode,默认3副本会一直报副本缺失警告;yarn-site.xml配置调度器和内存参数。 - SSH免密登录:用
ssh-keygen -t rsa生成密钥,然后把公钥加到authorized_keys里。这个不做好,每次启动HDFS都会卡在输入密码那一步。 - 格式化NameNode:这一步只做一次,格式化之前确保
hdfs-site.xml里配置的dfs.namenode.name.dir目录是空的,否则会报“NameNode already formatted”。 - Hive安装:把
hive-site.xml里配好MySQL连接,把MySQL驱动JAR放到Hive的lib目录下。注意Hive 3.1.3要用8.0.20以上的MySQL驱动,太老的驱动会报SSL连接错误。 - Spark解压:Spark装起来最简单,解压、配
spark-env.sh里的JAVA_HOME和SPARK_MASTER_HOST就能跑,但别忘了把hive-site.xml软链到Spark的conf目录。
2.3 关于伪分布式和集群的选择
很多读者问过我:做课程设计或者练习项目,到底要不要搭一个三节点的集群?
我的建议是:伪分布式完全够用。伪分布式部署下,NameNode、DataNode、ResourceManager、NodeManager都跑在同一台机器上,存储和计算能力确实有限,但足以支撑百万级的空气质量数据跑完整的离线流程。等把整个链路跑通,再去理解集群部署的知识点,看什么分布式协调、数据副本、资源队列这些才不会是雾里看花。
另外伪分布式有一个隐藏福利:单机上调试Spark作业的时候,日志直接看控制台,出了问题不需要跑三台机器上去翻日志,排错效率高很多。等逻辑没问题了,把代码提交到集群跑,只需要把地址从local[*]改成yarn集群模式,其余基本不用动。
3. 数据从哪来、怎么落库:采集、分区表设计、存储格式
3.1 数据源选型
空气质量预测必须要两类数据:污染物浓度数据和气象数据。
污染物数据,公开渠道里最靠谱的是各地生态环境监测中心发布的逐小时数据,以及一些汇总型开放的站点数据。也有不少学术公开数据集,比如UCI的AirQuality数据集,虽然年份老一点,但字段齐全,适合先跑通流程。气象数据可以用和风天气、高德开放平台这类API,按经纬度或者城市代码拿逐小时温度、湿度、风速、风向、气压。
我在这个项目里用的是“多城市多站点逐小时污染浓度 + 对应的气象观测值”。采集频率设定为每小时一次,定时任务去拉数据,拉到之后落到本地临时目录,然后上传到HDFS。
3.2 Python采集脚本的结构
采集层用Python写一个定时采集器,核心逻辑大概是这样:
python复制import requests
import pandas as pd
from datetime import datetime
def fetch_air_quality(city_code, api_key):
url = "https://api.example.com/air/hourly"
params = {
"city": city_code,
"key": api_key,
"start": datetime.now().strftime("%Y%m%d%H"),
"hours": 24,
}
resp = requests.get(url, params=params, timeout=10)
data = resp.json()
records = []
for item in data["data"]["list"]:
records.append({
"city": city_code,
"station": item["station_code"],
"datetime": item["time"],
"pm25": item["pm2p5"],
"pm10": item["pm10"],
"so2": item["so2"],
"no2": item["no2"],
"co": item["co"],
"o3": item["o3"],
"temp": item["temp"],
"humidity": item["humidity"],
"wind_speed": item["wind_speed"],
"wind_direction": item["wind_dir"],
"pressure": item["pressure"],
})
df = pd.DataFrame(records)
# 写出成按小时命名的文件,方便后面按分区装载
df.to_csv(f"/data/air/{city_code}/{datetime.now().strftime('%Y%m%d%H')}.csv",
index=False)
这个脚本写清楚之后,放到crontab里每小时执行一次。要注意的是:采集端一定不要直接写Hive表,先落成CSV文件,再由后续的Hive装载任务把数据导入分区。这样采集和存储解耦,采集挂了不影响下游,Hive表结构调整了也不需要改采集脚本。
3.3 Hive表结构与分区设计
Hive表设计是我认为这个项目里最不应该随意的地方。空气质量数据按小时更新,查询的时候基本是按城市+时间范围来查,所以分区策略很明确:按城市分区 + 按日期分区,或者直接用“日期为一级分区、城市为二级分区”。
建表语句大概是这样的:
sql复制CREATE EXTERNAL TABLE air_quality (
station STRING COMMENT '监测站点编码',
dt STRING COMMENT '时间点,格式yyyy-MM-dd HH:00:00',
pm25 DOUBLE,
pm10 DOUBLE,
so2 DOUBLE,
no2 DOUBLE,
co DOUBLE,
o3 DOUBLE,
temp DOUBLE,
humidity DOUBLE,
wind_speed DOUBLE,
wind_direction DOUBLE,
pressure DOUBLE
)
PARTITIONED BY (
city STRING,
day STRING
)
ROW FORMAT DELIMITED FIELDS TERMINATED BY ','
STORED AS TEXTFILE
LOCATION 'hdfs://localhost:9000/data/air_quality';
为什么要用外部表而不是内部表?因为原始CSV文件是采集脚本写出来的,Hive外部表直接管理HDFS上的文件,即使把表删了数据文件还在,不容易误删。存储格式上用TEXTFILE起步,简单直观,方便排查数据问题。等到数据量真的大到需要压缩存储,再考虑ORC或者Parquet也不迟,不用一开始就追求性能指标。
装载数据用动态分区:
sql复制INSERT OVERWRITE TABLE air_quality
PARTITION (city, day)
SELECT
station, dt, pm25, pm10, so2, no2, co, o3,
temp, humidity, wind_speed, wind_direction, pressure,
city, day
FROM staging_table;
实际操作时我发现一个小坑:分区字段的数据类型不要用INT。曾经有人把“20250101”这种日期设计成INT分区,看似没问题,但后续在Spark里做过滤、关联的时候,类型转换很容易出错。直接用STRING分区,2025-01-01这种格式清晰可见,而且能和Python的datetime字符串天然对齐。
4. Spark SQL清洗与特征工程,真正出成果的环节
4.1 清洗规则:先去重再补缺失
数据入库之后,下一步就是用Spark做清洗和特征工程。这一阶段决定了后面模型的输入质量,也最体现工程能力。
清洗规则我总结为四步:
- 去重:同一站点同一时间点可能被采集两次。用Spark的dropDuplicates,按
station和dt去重,保留最后一条即可。因为采集任务可能因为网络重试产生重复请求,直接按主键逻辑去重是最稳妥的。 - 缺失值处理:空气质量监测数据经常会有某个小时的空值,原因是设备维护或者通信中断。处理方式不能一刀切。对于连续缺失不超过3小时的,用线性插值;缺失超过3小时的,用“同一站点、最近7天同时刻的中位数”填充——因为污染物浓度有很强的日周期性,用同时刻中位数比用全天均值合理得多。第三天做特征的时候,再加一个
is_imputed标记列,让模型知道这个值是填补的,对最后预测结果会有帮助。 - 异常值处理:PM2.5的仪器偶尔会出现极端数值,比如传感器故障导致瞬间值变成9999。用3σ原则或者固定上下限(比如PM2.5超过800就认为是异常)都可以,处理方式是替换为缺失值再走填充逻辑。不要直接删除,因为时间序列的连续性对后续特征计算很重要。
- 时间归一化:把
dt统一成标准格式,并且加一列hour_of_day、day_of_week,这些后面做特征工程直接能用。
完整的Spark清洗任务逻辑,主要代码是这样:
python复制from pyspark.sql import SparkSession
from pyspark.sql.functions import col, count, when, isnan, row_number
from pyspark.sql.window import Window
spark = SparkSession.builder \
.appName("air_quality_etl") \
.config("spark.sql.warehouse.dir", "hdfs://localhost:9000/user/hive/warehouse") \
.enableHiveSupport() \
.getOrCreate()
df = spark.sql("SELECT * FROM air_quality WHERE day >= '2024-01-01'")
# 去重
df = df.dropDuplicates(["station", "dt"])
# 标记异常值
df = df.withColumn(
"pm25",
when(col("pm25") > 800, None).otherwise(col("pm25"))
)
df = df.withColumn(
"pm10",
when(col("pm10") > 1000, None).otherwise(col("pm10"))
)
# 缺失统计
total_cnt = df.count()
missing_cnt = df.filter(col("pm25").isNull()).count()
print(f"missing ratio pm25: {missing_cnt / total_cnt:.4f}")
# 使用fillna按均值填充简单场景,或注册窗口函数做插值/中位数填充
df = df.fillna({"pm25": 0, "pm10": 0})
注意,上面的fillna只是一个简化写法。真正做线性插值的时候,我是用窗口函数按站点分组、按时间排序,然后取前值和后值做平均:
python复制from pyspark.sql.functions import lag, lead
w = Window.partitionBy("station").orderBy("dt")
df = df.withColumn("pm25_prev", lag("pm25", 1).over(w))
df = df.withColumn("pm25_next", lead("pm25", 1).over(w))
df = df.withColumn(
"pm25_interp",
when(
col("pm25").isNull() & col("pm25_prev").isNotNull() & col("pm25_next").isNotNull(),
(col("pm25_prev") + col("pm25_next")) / 2.0
).otherwise(col("pm25"))
)
4.2 特征构造:滞后特征和滑动窗口是核心
清洗完后,真正的重头戏是特征工程。时间序列预测里,最有效的特征往往不是气象变量本身,而是目标变量的历史值,也就是滞后特征。
我构造的特征分三类:
- 时间类:hour_of_day(小时)、day_of_week(星期几)、is_weekend(是否周末)、month(月份)。这些反映空气质量的日变化和季节变化规律。
- 滞后类:lag_1(上一小时)、lag_3、lag_6、lag_12、lag_24(前一天同一时刻)、lag_168(上周同一时刻)。这个组特征对短时预测特别关键,因为PM2.5浓度变化有很强的惯性。
- 滑动窗口类:过去6小时的均值、过去24小时的最大值、过去24小时的最小值,以及PM2.5与PM10的比值变化率。窗口均值能平滑噪声,最大值能捕捉污染过程峰值。
在Spark里构造滞后特征,本质上就是窗口函数加lag:
python复制from pyspark.sql.functions import lag, mean, max
w_station = Window.partitionBy("station").orderBy("dt")
df = df.withColumn("pm25_lag_1", lag("pm25", 1).over(w_station))
df = df.withColumn("pm25_lag_3", lag("pm25", 3).over(w_station))
df = df.withColumn("pm25_lag_6", lag("pm25", 6).over(w_station))
df = df.withColumn("pm25_lag_24", lag("pm25", 24).over(w_station))
df = df.withColumn("pm25_lag_168", lag("pm25", 168).over(w_station))
滑动窗口稍微复杂一点,需要用rangeBetween:
python复制w_24h = Window.partitionBy("station").orderBy("dt").rangeBetween(-23, 0)
df = df.withColumn("pm25_mean_24h", mean("pm25").over(w_24h))
这里有一个逻辑上的坑必须讲清楚:构造特征时,窗口内只能使用过去的数据,不能使用当前时刻和未来的数据,否则就是数据泄露。比如你想预测t时刻的PM2.5,那么t时刻的实测PM2.5就不能作为特征,只能用t-1及之前的数据。很多第一次做时间序列的人在这里翻车,模型在训练集上表现好得惊人,一上线预测就乱套,原因就是泄露。Spark窗口函数算lag_1恰好是从上一行取值,天然规避了这个问题,但如果有人手动把整个小时的实测值一起塞进特征,那测试时就会发现“特征不存在”或者“预测结果跟真实值几乎重叠但实际环境没法用”。
4.3 特征宽表导出
特征工程做完后,为了方便Python侧训练,我会用Spark把最终结果写出到HDFS,再导出成CSV或者用Hive表保存:
python复制df_selected = df.select(
"station", "dt", "pm25",
"hour_of_day", "day_of_week", "is_weekend", "month",
"pm25_lag_1", "pm25_lag_3", "pm25_lag_6",
"pm25_lag_24", "pm25_lag_168",
"pm25_mean_24h",
"temp", "humidity", "wind_speed", "pressure"
)
df_selected.write \
.mode("overwrite") \
.format("parquet") \
.save("hdfs://localhost:9000/data/air_feature/")
# 也可以写回Hive表,便于下游SQL查询
df_selected.write \
.mode("overwrite") \
.saveAsTable("air_quality_feature")
用Parquet格式导出比CSV快很多,而且列式存储在后续读取到Pandas的时候内存占用也小。
5. 时间序列预测模型的选型、训练与评估
5.1 搞清楚你是要“统计预测”还是“机器学习预测”
时间序列预测这个方向有一个典型的认知误区:很多人以为ARIMA是唯一正解,也有很多人觉得LSTM才是王道。实际项目里,这两类模型各有适合的场景。
我在这套系统里把模型分成了两个阵营:
- 经典统计模型:ARIMA、SARIMA、Prophet。优点是逻辑清晰、可解释性强、对单条序列效果好;缺点是不方便融入多维度特征,比如气象因素、节假日标记等。
- 机器学习模型/深度学习模型:XGBoost、LightGBM、随机森林、LSTM。优点是可以把前一步构造的滞后特征、气象特征全部塞进去,非线性拟合能力强;缺点是训练和调参成本高,对特征工程质量要求很高。
从实测效果看,在这个场景下,LightGBM的综合表现明显好于ARIMA和LSTM。原因很直接:空气质量受气象因素影响非常显著,LightGBM可以充分利用气象特征和滞后特征的交叉作用,而ARIMA天生只能吃序列本身的历史值。LSTM虽然理论上更强,但训练需要大量样本,数据量和调参技巧不够时,效果反而不如树模型稳。
5.2 基线模型不能省,先跑一个朴素预测当标尺
在我做的所有预测项目里,无论用什么高级模型,第一步一定是先跑一个简单的基线模型。原因很简单:没有基线,你无法判断复杂模型带来的提升到底值不值这个复杂度。
空气质量预测里最自然的基线是**“上一时刻值”(lag_1)**,也就是预测下一小时的浓度等于当前小时的浓度。从时间序列的惯性来看,PM2.5短时间内的变化相对平缓,这个基线的RMSE其实不会差太多。
第二个基线是**“同时刻历史均值”**,用过去两周相同小时的平均值作为预测值,等价于把星期效应考虑进去。如果模型连这个都打不赢,说明特征和参数还有大问题。
我在实测里,lag_1基线的RMSE大概在25μg/m³左右(城市区域内),LightGBM可以把RMSE压到18以下。如果谁的LSTM调了半天还不如lag_1,赶紧回来看特征工程和样本切分,问题几乎一定出在那边。
5.3 训练集的切分方式是最容易被带偏的地方
时间序列模型的训练/验证/测试集划分,绝对不能随机抽样。很多教程里用train_test_split(random_state=42)做随机切分,这在普通分类任务里没问题,但在时间序列里就是灾难——你拿未来数据训练模型去预测过去,在时间序列的语境里等于作弊。
正确的做法是按时间顺序切分:
python复制import pandas as pd
from sklearn.model_selection import TimeSeriesSplit
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import mean_absolute_error, mean_squared_error
# 假设 df_feature 是Spark导出的特征宽表
# 按时间排序
df_feature = df_feature.sort_values(["station", "dt"])
# 用最后30%的时间段作为测试集
split_idx = int(len(df_feature) * 0.7)
train_df = df_feature.iloc[:split_idx]
test_df = df_feature.iloc[split_idx:]
X_train = train_df.drop(columns=["station", "dt", "pm25"])
y_train = train_df["pm25"]
X_test = test_df.drop(columns=["station", "dt", "pm25"])
y_test = test_df["pm25"]
model = RandomForestRegressor(n_estimators=200, max_depth=10)
model.fit(X_train, y_train)
y_pred = model.predict(X_test)
print("MAE:", mean_absolute_error(y_test, y_pred))
print("RMSE:", mean_squared_error(y_test, y_pred, squared=False))
这里还要注意一个细节:如果你的数据里有多个站点,切分时要么按时间整体切分(所有站点同时切),要么单独对每个站点切分,但绝不能把同一时间的同一条记录同时分到训练集和测试集里,否则会有信息泄漏。
5.4 模型指标:MAE还是MAPE,要看你怎么用
空气质量的预测误差评价,我建议同时看RMSE和MAE,MAPE只作为参考。
为什么?MAPE在PM2.5浓度很低的时候会出现“分母趋近于0”的问题,比如真实值5,预测值10,误差是100%,但实际差值只有5μg/m³,这种情况下MAPE会误导你对模型真实性能的判断。
RMSE对异常大误差敏感,适合判断有没有极端预测偏差;MAE反映平均绝对误差,更适合业务解释:“预测值和真实值平均差多少”。在这个项目里,我会用RMSE作为主要调参指标,给业务看结果时用MAE。如果MAE能控制在15μg/m³以内,基本可以满足“未来24小时的趋势预测”这个需求。
6. 全流程调度、部署和真实翻车现场
6.1 把链路串起来:从采集到预测的定时调度
整个系统跑通之后,需要一个调度机制把它们按顺序跑起来。简单场景下,写一个Shell脚本,按时间顺序调用即可:
bash复制#!/bin/bash
# 1. 采集
python3 /opt/air/collect.py
# 2. 上传到HDFS
hdfs dfs -put /data/air/$CITY/$DATE.csv /data/air/$CITY/
# 3. 装载到Hive表(执行一条SQL)
hive -e "LOAD DATA INPATH '/data/air/$CITY/$DATE.csv' INTO TABLE air_quality PARTITION(city='$CITY', day='$DATE')"
# 4. Spark作业做清洗和特征工程
spark-submit \
--master yarn \
--deploy-mode client \
--executor-memory 4g \
--executor-cores 2 \
/opt/air/etl.py
# 5. Python训练模型
python3 /opt/air/train.py
# 6. Python启动预测服务
python3 /opt/air/serve.py
把这个脚本挂到crontab里,每天凌晨和每小时分别执行不同阶段的任务:小时级任务只做采集和增量ETL,日级任务做特征宽表重建和模型重训。
如果以后再学调度框架,可以用Airflow替换掉Crontab,DAG化的任务依赖管理会清晰很多,对大数据项目也是加分项。但作为第一版,Crontab已经能解决问题,不要一上来就整复杂框架,把时间浪费在调度系统本身的维护上。
6.2 翻车现场:三个我实际踩过的坑
讲几个真实的报错和解决过程,这些都是教程里很少写但实操中大概率遇到的。
坑一:Spark和Hive的metastore连接不上
现象是Spark SQL执行spark.sql("CREATE TABLE xxx")成功,但重新打开SparkSession之后表就没了,或者读Hive已有的表报Table not found。
排查过程:先确认Hive能正常建表和查询,说明Hive本身没问题。再看Spark的日志,发现它启动时根本没有加载Hive的metastore配置。原因就是之前说的,没把hive-site.xml放到Spark的conf目录下。Spark的enableHiveSupport()这个配置只代表“允许Spark使用Hive的metastore”,但如果Hive的配置(数据源地址、MySQL账号密码)找不到,它就退化成独立的内置metastore,所有表只存在内存里。
解决办法就是把$HIVE_HOME/conf/hive-site.xml复制到$SPARK_HOME/conf/下,重启Spark作业就正常了。
坑二:数据倾斜,某一个station的数据量特别大
做特征工程时,有一个城市的中心站点数据量远超其他站点,导致Spark的某个Executor任务跑了很久,其他任务全闲等。
排查过程:看Spark UI里各个Task的处理时间,发现有一个Task的输入数据量是其他Task的几十倍。这是典型的倾斜问题。当时我用的窗口函数和分组聚合都是按station分区的,热点站点的数据全堆在一个分区里。
解决办法:给热点城市单独做一次聚合,再把结果union到一起;或者调整Spark的spark.sql.shuffle.partitions,从默认200提升到400,让数据分得更散。实际处理后整个作业从25分钟降到了8分钟。
坑三:模型上线后发现预测值整体偏低
训练时RMSE正常,但到了冬季重污染天气,模型预测值明显低于真实值。排查下来发现,训练数据里冬季样本占比太少,模型在“高浓度区间”的样本上拟合不足。
解决办法是在特征中加入“城市采暖季”这个标志位,并且在训练时对高浓度样本做了加权,让模型更关注尾部的重污染事件。同时把训练的滚动窗口从1年扩大到3年,覆盖到不同年份的冬季污染过程。这个问题的本质是数据分布不均衡,在时间序列里特别容易被忽略。
6.3 数据可视化与前端查询
预测结果出来后,最后一步就是让用户能看。我用的方案是把预测结果写回一张Hive表,然后通过Spark或者Presto提供OLAP查询,后端再提供API给前端图表展示。
Hive表结构大概是:
sql复制CREATE TABLE air_quality_forecast (
city STRING,
station STRING,
forecast_date STRING,
hour INT,
pm25_pred DOUBLE,
model_version STRING
)
PARTITIONED BY (day STRING);
前端的展示用ECharts画折线图,一条线是历史实测值,一条线是未来24小时预测值,叠在一起很直观。为了减少查询压力,用了Redis做了一层缓存,城市和日期的查询结果缓存5分钟,足够应对展示场景。
可视化这个环节本质上不是重点,但能让整个项目的完整度提升一个档次。如果做课程设计或者比赛答辩,这一块能直观展示“数据→预测→业务”的闭环,分数通常都会高不少。
写在最后的一点经验
如果你现在正在从头搭这套系统,我的建议是别急着上手写代码,先把数据流画出来,想清楚每一层做什么、产出物是什么、谁来消费这个产出物。这个项目的复杂度其实不是某一个技术栈有多难,而是多个组件的协作关系容易让人晕——Hive表结构设计不合理,后面Spark清洗就很别扭;特征工程没防泄露,模型再强也没用;调度顺序错了,数据就对不上。
我个人在跑完这个项目之后最大的体会是:大数据技术的选型,永远要回到业务场景里去判断。空气质量预测这个题目,用Hadoop生态不是炫技,而是因为数据量上来之后,存储、管理、计算、重训这套流程的确需要一个规范的平台来承载。希望这篇拆解能帮你少走几轮弯路,把时间花在真正有产出的特征和模型调优上。
