我见过不少选“空气质量预测系统”做毕设的同学,最开始都是被大数据分析可视化这几个词吸引过来的。但真正动手以后,才发现难点根本不在算法,而在数据怎么从一堆乱七八糟的原始文件变成能喂给机器学习的结构化数据,也在于把Hadoop、Spark、Hive这一整套工具串起来之后,系统能不能稳定跑通,以及在答辩现场能不能流畅地展示出几张像样的可视化大屏。这篇文章就把我从头帮人梳理这类毕设项目时积累的细节写出来,包括每个环节该怎么设计、有哪些坑要提前绕开,以及最后怎么把源码、论文文档、PPT和演示串成一套能拿得出手的东西。如果你正在准备大数据方向的毕业设计,或者想快速搭一套空气质量分析可视化系统,这篇内容可以直接当操作手册来用。
1. 动手前先定位:这题目到底考的是工程能力还是算法能力
很多同学一看到“预测”两个字,就开始抱着一堆机器学习理论啃,生怕模型不够高级。实际上,本科毕设的空气质量预测系统,评委重点看的往往不是你的预测准确率能超过某个论文多少个百分点,而是你能不能把一条完整的数据链路跑通:数据从哪来,怎么存,怎么洗,怎么分析,怎么预测,怎么展示。这是一个典型的工程型题目,不是算法竞赛题。
1.1 项目本质是“全链路”而不是单一模型
如果把这个毕设拆开,能看到五个必须打通的环节:
- 数据采集与接入:空气质量监测数据通常是按小时生成的文本或表格,可能来自某个开放平台下载,也可能是一批模拟生成的csv文件。
- 数据存储与建仓:原始数据不能直接拿来机器学习,需要先落到HDFS,再通过Hive建表提供查询能力。
- 数据清洗与特征构造:用Spark对空值、异常值、单位不统一的问题做处理,把日期、天气、站点等扩展成可用特征。
- 模型训练与预测:用Spark MLlib或者Spark内置的回归、树模型去预测未来的PM2.5浓度或空气质量等级。
- 可视化与结果输出:把分析和预测结果通过后端接口暴露给前端页面,画出趋势图、分布图和预测曲线。
这五个环节任何一个掉链子,整个系统都会显得不完整。很多同学前期只顾着琢磨Hive分区语句怎么写,结果到答辩前一个星期才发现可视化还用原生JS在铺div,页面丑得没法看。合理做法是:第一周就把五个环节全部跑通一个最小版本,之后再逐层优化。
1.2 功能边界的合理切分
不是所有功能都要做进去,也不是做得越多越好。一套标准的空气质量预测分析可视化系统,我建议按下面的功能边界来切:
- 历史数据查询:按城市、按站点、按时间范围查询监测记录。
- 统计分析:计算日平均浓度、月均变化、污染物环比,生成基本图表。
- 空气质量评价:按照通用空气质量指数(AQI)计算出等级,标注污染程度。
- 趋势预测:基于历史污染物浓度和气象条件,预测未来24小时或48小时的PM2.5变化。
- 可视化大屏:在一页里展示地图分布、时序曲线、排名表格、指标卡片。
超出这个范围的需求,比如做多城市气象联动、做三维粒子扩散模拟,都不是本科毕设的必要项。功能多确实能显得“工作量足”,但工作量也要能写清楚、能回答提问才行。做得越杂,漏洞越多。
1.3 预期工作量与实际工作量怎么对齐
以我辅导过的项目为例,一个人完成这个系统,纯开发时间大概在30到40个工作日。数据部分最耗时,大概占四成;模型调参占两成;可视化占两成;剩余是文档和答辩准备。如果前期发现数据获取很难,优先使用自带的模拟数据生成器,但要在设计说明里写清楚数据格式和生成逻辑。答辩时主动说“我用了模拟数据,来源于某环境监测公开数据的结构”,比遮遮掩掩说“数据是真实爬来的”要好得多。
2. 技术栈拆解:Hadoop、Spark、Hive各自负责哪一段
毕设题目里同时出现这三个词,不是让你把它们混为一谈。它们在这套系统里的角色非常清楚:Hadoop解决“怎么存”,Hive解决“怎么查”,Spark解决“怎么算”。
2.1 为什么数据不能都放MySQL
如果数据集只有几千条,用MySQL确实更快。但空气质量系统的高频监测数据,按每小时一条记录计算,一个站点一年就是8760条,十个站点三年是26万条,加上气象数据、预测结果表,总量很快就突破百万级。数据规模一旦变大,单机关系型数据库在批量扫描和聚合统计上就吃力了。而Hadoop HDFS天然适合存这种结构化或半结构化的日志型数据:写一次,读多次,不更新,按时间追加。
更重要的是,毕设题目要考察的是“大数据处理思维”。你可以在系统里保留MySQL作为业务库,但主数据链路必须经过HDFS和Hive,这样才能体现出分布式存储与传统数据库的对比价值。在论文里我会建议写一小节专门的对比分析,比如“MySQL面对亿级记录时的索引膨胀和查询抖动”以及“HDFS+分区表带来的并行扫描优势”。
2.2 Hive表: 面向分析的数仓入口
Hive本身不是数据库,它是把SQL翻译成MapReduce或Spark任务的元数据服务。在空气质量系统里,Hive的作用是把HDFS上的杂乱CSV整理成有结构、有类型、有分区的表。比如原始文件里时间字段是“2024-01-05 14:00:00”,Hive里可以定义成STRING或TIMESTAMP,方便后续按天聚合。
分区是Hive查询性能的关键。按月份或按天分区,查询某段范围时可以直接跳过无关分区。我用过最合理的方案:主分区按天,二级分区按站点ID,这样既不会产生太多小文件,又能让单站点的查询速度提升好几倍。建表的细节我后面会专门给一段SQL示例,这里先记住一个原则:Hive表结构必须和分析需求一一对应,不要把所有字段堆在一张大宽表里。
2.3 Spark在整套流程里的三个角色
Spark是真正的计算引擎。在我们这个系统里,它同时承担三个任务:
- 数据清洗:读入原始CSV,过滤空行,修正异常值,统一时间格式,这一步可以写一个Spark Job。
- 离线分析和聚合:跑出每日平均浓度、AQI等级分布、污染排名等指标,可以直接用Spark SQL,也可以写DataFrame算子。
- 机器学习:Spark MLlib提供回归、树模型、特征转换工具,空气质量预测模型直接在Spark上训练和推理。
有一点容易被忽略:Spark和Hive需要配置好Hive支持。如果SparkSession里没有“enableHiveSupport()”,你后面用Spark SQL访问Hive表会一直抛出“Table not found”的错。这个坑我在配置环境时几乎每次都遇到,提前确认,能省下半天调试时间。
2.4 版本组合与运行环境细节
很多同学按网上教程装环境,结果Spark、Hadoop、Hive版本互相不兼容。我建议直接用以下这个稳定组合,都是我实测过的:
| 组件 | 版本 | 说明 |
|---|---|---|
| CentOS/Ubuntu | 7.x / 20.04 | 虚拟机或云服务器都可以 |
| JDK | 1.8 | 必须,很多高版本JDK会带Hive原生方法问题 |
| Hadoop | 3.2.x | 三个节点或单节点伪分布式均可 |
| Hive | 3.1.x | 使用MySQL存储Hive元数据 |
| Spark | 3.1.x | 与Hadoop 3.x配合较稳 |
| Scala | 2.12 | Spark发行版内置版本要匹配 |
如果只是本地演示,完全可以用单机伪分布式模式,不非要去部署三节点集群。但论文里要说明清楚“伪分布式与完全分布式的区别”,以及你选择单机的理由。老师不会因为你没用三节点而扣分,反而会认为你对分布式原理理解到位。
3. 从采集到入库:写出真正能跑通的数据处理链路
数据链路是整个系统的地基。我见过太多人花三天时间搭好Hadoop,结果卡在“数据怎么进Hive”这一步。实际上,完整流程可以抽象成三句话:原始文件放HDFS,Spark做清洗,Hive表做查询。
3.1 数据源怎么选:下载加模拟混合
真实的空气质量数据通常可以分成两类。一类是历史监测数据,文件结构里包含监测时间、站点名称、PM2.5、PM10、SO₂、NO₂、CO、O₃等字段,每小时一条。另一类是气象数据,包括温度、湿度、风速、风向、气压等。毕设里完全可以使用公开数据集,但要注意版权和口径,或者干脆按照真实格式自己写一个生成脚本,按时间连续生成多条记录,模拟出某市多个站点三年的数据。
我自己常用的做法是三步:
- 写一个Python脚本,生成某市8个站点、2021年到2023年逐小时监测数据,字段参考真实CSV结构。
- 把数据分成“原始数据”目录,不做任何加工,直接上传到HDFS的“/data/air/raw”。
- 再生成一份带有缺失值和异常值的数据,故意在时间序列里去掉几行、把PM2.5写成负数等,用于展示清洗能力。
这个设计在答辩时非常加分,因为体现出了“不是所有数据都是干净的”这一工程意识。论文里可以专门写一节“数据清洗需求分析”。
3.2 Spark清洗代码实战
用Scala写稳一点,但如果你更熟悉PySpark,用PySpark也完全可以。下面给一个精简但可运行的PySpark清洗流程:
from pyspark.sql import SparkSession from pyspark.sql.functions import col, when, to_timestamp, avg spark = SparkSession.builder \ .appName("AirQualityETL") \ .config("hive.metastore.uris", "thrift://localhost:9083") \ .enableHiveSupport() \ .getOrCreate() # 读取HDFS上的原始数据 df = spark.read.options(header=True, inferSchema=True) \ .csv("hdfs://localhost:9000/data/air/raw/") # 统一时间格式 df = df.withColumn("monitor_time", to_timestamp(col("time_str"), "yyyy-MM-dd HH:mm:ss")) # 过滤明显异常值:污染物浓度不能为负数 df = df.filter(col("pm2_5") >= 0) # 对站点标识去空格 df = df.withColumn("station_name", col("station_name").cast("string"))需要注意,清洗之后临时表得注册给Spark SQL用,后续要插入Hive分区表时,可以直接通过df.write.mode("overwrite").insertInto("air_quality_dwd")完成。这一步容易踩的坑是Hive表stored字段格式和Spark默认的Parquet不一样,建议在Hive建表时统一用stored as parquet,两边兼容性最好。
3.3 Hive表结构设计:明细层和汇总层分开
数据仓库一般会用分层思想。空气质量毕设虽然不用做得太重,但至少要区分明细层和汇总层。
明细表就是清洗后的原始记录,建议叫dwd_air_quality,按天分区:
CREATE TABLE dwd_air_quality ( station_name STRING, monitor_time TIMESTAMP, pm2_5 DOUBLE, pm10 DOUBLE, so2 DOUBLE, no2 DOUBLE, co DOUBLE, o3 DOUBLE, temperature DOUBLE, humidity DOUBLE, wind_speed DOUBLE ) PARTITIONED BY (dt STRING) STORED AS PARQUET;汇总表可以弱化成视图,或者直接建一张dws_air_daily表,存储每天每个站点的平均浓度和AQI等级:
CREATE TABLE dws_air_daily ( station_name STRING, dt STRING, avg_pm2_5 DOUBLE, avg_pm10 DOUBLE, max_pm2_5 DOUBLE, aqi_level STRING ) PARTITIONED BY (month STRING) STORED AS PARQUET;这里有一个容易犯的错误:把“分区字段”和普通字段混在一起。Hive分区字段是“伪列”,数据文件里不存储它,只会以目录的形式存在。如果你从CSV里读出来的数据里本身带着dt字段,写入分区表时不能用普通INSERT,要用动态分区或先去掉重复字段,否则会发生字段错位。
3.4 质量问题的处理经验
清洗流程里最常见的三类问题:
- 时间格式不统一:有的行是“2024-01-01”,有的是“2024/01/01 00:00”。统一用
DateTimeFormatter或to_timestamp强制转换,转换失败的行单独放入异常表。 - 传感器故障导致的连续0值:比如PM2.5连续12个小时都是0,这明显不是正常环境值,应该把这部分标记为缺失,而不是当作真实数据。
- 单位不一致:有些站点用微克每立方米,有些用毫克每立方米。处理时必须在代码里写清楚乘以1000的换算过程,并在论文里给出换算表。
遇到技术提问,能准确说出“我这个数据清洗过程一共过滤了多少条异常、保留有效记录多少条”,比把模型吹上天更能让答辩老师信服。
4. 从分析到预测:Spark MLlib的建模实操
空气质量预测是本项目最醒目的技术点。我们不需要把预测做成什么深度神经网络,用Spark自带的机器学习库足够,重点在于特征做得合理、评估说得清楚。
4.1 预测目标怎么定
最常见的方案是预测下一个时间点的PM2.5浓度,把它当成回归问题。回归问题比较好展示,输入一段历史数据,输出一条预测曲线,和真实曲线画在一起,视觉冲击力强。也可以把问题升级为“预测未来24小时是否会达到轻度污染”,这属于二分类,模型构建思路类似,但答辩时用二分类容易被人追问类别不均衡的问题,所以初版还是建议做回归。
模型预测上,我用过两种:线性回归作为baseline,随机森林回归作为主模型。线性回归能跑通逻辑,但面对空气污染这种非线性过程明显不够用。随机森林不需要做特征标准化,对缺失值容忍度较高,训练速度也快,很适合毕设的演示规模。你在论文里这样写:“线性回归作为基准模型,随机森林捕捉非线性特征,对比两者RMSE和R²”,就非常清晰。
4.2 特征工程的几个关键点
空气质量预测不能只拿历史PM2.5一条序列去预测。特征工程直接影响结果。我实际用下来的有效特征大概分三类:
- 历史污染物特征:当前前1小时、前3小时、前6小时、前12小时的PM2.5、PM10、SO₂等浓度。
- 气象特征:温度、湿度、风速。这些与污染物扩散强相关,尤其风速,在大风天气下PM2.5浓度会显著下降。
- 时间特征:小时、星期几、是否为节假日、是一天中的第几个时段。用
hour、dayofweek这类派生字段,不需要额外外部数据。
比如,用Spark构造“滞后1小时PM2.5”这个特征,可以用窗口函数:
from pyspark.sql.window import Window from pyspark.sql.functions import lag, col w = Window.partitionBy("station_name").orderBy("monitor_time") df_feature = df.withColumn("pm2_5_lag1", lag("pm2_5", 1).over(w)) \ .withColumn("pm2_5_lag3", lag("pm2_5", 3).over(w)) \ .withColumn("pm2_5_lag6", lag("pm2_5", 6).over(w))特征构造后,不要忘了把含有null的行剔除,因为前几行历史数据不足,预测目标就成了无本之木。也可以自己规定必须连续有12小时数据才能作为一组训练样本,这样模型输入更有意义。
4.3 用VectorAssembler把特征送进随机森林
Spark MLlib的流程是标准化的:先用VectorAssembler把所有特征列合并成一个向量,再放到RandomForestRegressor里。完整代码骨架如下:
from pyspark.ml.feature import VectorAssembler from pyspark.ml.regression import RandomForestRegressor from pyspark.ml.evaluation import RegressionEvaluator feature_cols = ["pm2_5_lag1", "pm2_5_lag3", "pm2_5_lag6", "temperature", "humidity", "wind_speed", "hour", "dayofweek"] assembler = VectorAssembler(inputCols=feature_cols, outputCol="features") rf = RandomForestRegressor(labelCol="label", featuresCol="features", numTrees=100, maxDepth=10, seed=42) from pyspark.ml import Pipeline pipeline = Pipeline(stages=[assembler, rf]) train_df, test_df = df_feature.randomSplit([0.8, 0.2], seed=42) model = pipeline.fit(train_df) predictions = model.transform(test_df)有几个参数需要当场能说清:numTrees为什么不是越多越好,maxDepth太大会过拟合。建议在论文对比实验里分别跑maxDepth=5,10,15,列一张表对比RMSE。哪怕最终选的是10,也要说明理由,这样显得你是真的调过参而不是只跑了一次。
4.4 模型评估指标怎么算
回去评估不能只看表面正确率。回归问题要用RegressionEvaluator计算RMSE、MAE和R²:
evaluator_rmse = RegressionEvaluator(labelCol="label", predictionCol="prediction", metricName="rmse") evaluator_mae = RegressionEvaluator(labelCol="label", predictionCol="prediction", metricName="mae") evaluator_r2 = RegressionEvaluator(labelCol="label", predictionCol="prediction", metricName="r2") rmse = evaluator_rmse.evaluate(predictions) mae = evaluator_mae.evaluate(predictions) r2 = evaluator_r2.evaluate(predictions)以PM2.5预测为例,RMSE在15到25之间都算可接受,如果RMSE只有个位数,反而要怀疑是不是数据集太简单或特征泄漏。所谓特征泄漏,就是指预测目标已经被某些特征间接包含。比如你把目标时间的PM2.5本身也当成特征了,那模型评分自然高得离谱,但实际毫无意义。答辩时要能解释清楚训练集和测试集是按时间切分还是随机切分,我建议按时间顺序切前80%当训练、后20%当测试,更符合真实场景。
5. 可视化与分析大屏:让结果自己会说话
可视化是整个系统里最直观的部分,也是答辩老师停留时间最长的页面。哪怕数据量不大,只要大屏做得清晰、信息层次分明,就非常加分。我见过不少项目用的是开源Dashboard模板,但完全不理解里面的数据和后端接口的映射关系,结果老师随便点一个图表就问住了。可视化的原则是“每一块图表都要能解释来源”。
5.1 可视化架构设计
典型的架构是:
- 前端:Vue + ECharts,最常用,折线图、地图、仪表盘组件生态完善。
- 后端:Spring Boot,提供接口给前端,用MyBatis或JDBC查询MySQL。
- 数据来源:Spark和Hive的处理结果,定期写入MySQL的业务表。
为什么非要经过MySQL?因为前端界面查询要求毫秒级响应,不能让用户点一下页面就去跑一个Spark Job。每天凌晨把Hive汇总表导出到MySQL,页面读MySQL,这是一个非常合理的设计。
5.2 大屏应该展示哪几块核心内容
我建议大屏沿着“现状-历史-预测”这条主线去切块:
- 左侧:当日各站点AQI排名,用横向柱状图或列表呈现,可以高亮污染最严重的站点。
- 中间:城市站点分布地图,用GeoJSON表示站点位置,点大小根据PM2.5浓度动态变化,颜色从绿色到紫色表示污染程度。
- 顶部:核心指标卡片,显示当前AQI、首要污染物、监测站点数、今日均值。
- 右侧:12小时PM2.5趋势折线图,把真实值和预测值画在同一张图上。
- 底部:污染物浓度时间热力图,用x轴是时间、y轴是污染物类型、颜色深浅表示浓度。
地图是最大的亮点。实现时可以下载一个对应城市的GeoJSON,用ECharts的registerMap注册地图,然后设置scatter或effectScatter系列。需要注意城市坐标需要和GeoJSON的坐标系一致,不然点会飘到地图外。我踩过这个坑,第一次上大屏时两个站点直接显示到了邻省位置,后来才发现经纬度用的是WGS84,地图数据是GCJ02,统一修正后才正常。
5.3 从Hive导出到MySQL的定时同步方式
这里给一个省事的方法:写一个Spark Job,读取dws_air_daily表,把当日数据写入MySQL。这样既能体现数据处理流程,又不需要引入额外调度框架。简化的代码片段:
df_daily.write \ .mode("overwrite") \ .format("jdbc") \ .option("url", "jdbc:mysql://localhost:3306/air_visual?characterEncoding=utf8") \ .option("dbtable", "daily_aqi") \ .option("user", "root") \ .option("password", "******") \ .save()当然,每次全量覆盖确实比较粗暴,但毕设数据量不大,这种方式足以支撑演示。后续优化方向可以改成按日期增量写入,论文里提一句“增量同步策略”就够了。
5.4 接口字段和前端联调的注意事项
后端接口设计最好统一用一套响应结构,比如{code: 200, data: {...}}。前端拿到数据后直接渲染,不要在后端把图表配置也包进去。接口字段和图表字段命名尽量一致,避免大小写混乱。
举一个实际案例:后端返回avg_pm25,前端写成了avg_PM2_5,联调时浪费了一个小时。后来我在项目里统一用驼峰命名法,字段名全部加注释,再也没出过这种低级问题。前端展示单位也要统一,后端返回的值如果单位是微克每立方米,前端就直接显示“μg/m³”,不要在页面里写“ug/m³”。
可视化部分最容易出现的答辩问题是:“你的数据实时性如何?”这时候回答“我的系统是离线批处理架构,数据每天定时更新,适合历史趋势分析,不适合分钟级实时告警”,显得诚实且专业。如果硬说自己实时,但页面明明没有任何流式处理机制,反而会被追着问细节。
6. 毕设交付四件套:源码、LW文档、PPT和答辩讲解怎么整
最后一部分专门说交付物。很多学生技术实现到80%,结果文档写得像用户手册,PPT全是截图拼凑,演示时手忙脚乱,最后评分被拉低。这非常可惜。
6.1 源码结构要能自圆其说
源码并不是能跑就行,结构要让人一眼看出设计思路。我建议按功能模块分目录,例如:
air-quality-bi/ ├──>