简介:完整可运行的Python基于Django城市PM2.5空气质量数据可视化分析毕业设计项目,面向计算机相关专业毕业生、课程设计与期末大作业学习者。项目基于北京、上海、广州、成都、沈阳五城市六年PM2.5观测数据,用Python完成清洗与分析,研究PM2.5浓度随月份、季度、时间段的趋势变化,以及与温度、露点、气压、风向、相对湿度等气象因子的关系,并将结果导出CSV;后端采用Django,前端使用ECharts将多维度分析结果以图表形式直观呈现。压缩包共65个文件,以CSV数据结果、Python源码、HTML模板、SQL数据库脚本为主,另有Pyc编译文件与XML配置文件,整体12.37MB;目录按数据、后端应用、前端模板等模块分层,便于直接运行和二次开发。项目包含全部源码、数据库及说明文档,说明文档覆盖开发环境配置与部署要点,经严格调试可确保运行,适合作为毕业设计或课设的完整参考方案。目前已有131人学习下载。
1. Django毕业设计中的PM2.5可视化项目到底在做什么
先看结论:这个项目能高分通过,不是因为网站页面做得花哨,而是因为它把“数据获取—清洗—分析—存储—可视化”整条链路走完了。拿到源码后你会发现,真正吃功夫的部分在 get_data 目录下的通用脚本与数据导入.py,后面的 Django 和 Echarts 只是把分析结果换个形式展示出来。对于正在做毕业设计的学生,这套五城市 PM2.5 数据分析项目可以直接当模板使用,换一套城市数据、调整几个分析维度,就能变成另一个题目。它覆盖的知识点包括 pandas 数据聚合、Django ORM、MySQL 存储、Echarts 数据可视化,基本对应数据库课程设计、软件工程类课程和企业级数据可视化项目的核心要求。适合计算机相关专业学生借鉴,也适合想补全 Django 全栈链路的新手。
2. 五城市PM2.5数据清洗与聚合:那些CSV结果是怎么算出来的
2.1 原始数据字段与清洗起点
源码包里 get_data 下放着北京、上海、广州、成都、沈阳五个城市的原始 CSV,以及“一年中各个月份变化”“一天中不同时段”“pm2.5与温度之间的关系”等一批分析结果。这些结果不是手工统计的,而是通用脚本.py 用 pandas 算完导出。要复现,得先处理一个实际问题:五个 CSV 的字段命名并不统一,有的叫PM2.5,有的叫质量浓度,时间字段有的是字符串,有的已经是 datetime 对象。真实数据分析里最耗时间的就是这一步。
常见做法是写一个统一的加载函数,把字段名规范化后再合并:
import pandas as pd def load_city(path): df = pd.read_csv(path, encoding='utf-8') df.columns = [c.strip().lower() for c in df.columns] df = df.rename(columns={ 'pm2.5': 'pm25', '质量浓度': 'pm25', '时间': 'datetime', 'date': 'datetime' }) # 统一时间字段 df['datetime'] = pd.to_datetime(df['datetime']) # 浓度转数值,非数字变成 NaN df['pm25'] = pd.to_numeric(df['pm25'], errors='coerce') return df.dropna(subset=['pm25', 'datetime'])pd.to_numeric的errors='coerce'会把类似"1,234"或空字符串转成 NaN,避免后续groupby时数据类型报错。注意最后dropna只删浓度和时间为空的行,气象字段缺失可以保留,因为同一行的气象列不一定参与所有分析。加载五个城市后,还需要给每个 DataFrame 加一列城市名,再pd.concat合并成一张总表,后续所有分析都基于这张总表,否则统计口径对不上。
2.2 月份和时段聚合:季节性与昼夜规律怎么算
合并后的总表先提取月份和小时两个分组键。项目里的“一年中各个月份变化.csv”描述的是多年同月均值,不是某一年的值,所以分组键是month,聚合方式是mean:
df['month'] = df['datetime'].dt.month month_stat = df.groupby(['city', 'month'])['pm25'].mean().reset_index() month_stat.to_csv('一年中各个月份变化.csv', index=False, encoding='utf-8-sig')这里用utf-8-sig而不是utf-8,是因为 Windows 上的 Excel 打开无 BOM 的 UTF-8 文件会出现中文乱码,Django 后续再读这个 CSV 时依然可以按utf-8-sig读取,不影响解析。按城市分组是为了让前端能做多城市对比图,如果只按月份不分城市,北京和广州的年变化曲线就会被平均掉,看不出南北差异。
“一天中不同时段.csv”完全同理,把dt.month换成dt.hour即可。需要注意的是,如果原始数据是小时粒度,直接算均值没有问题;如果原始数据是每日一条,那么“一天中不同时段”这一列就没有意义。项目文件里明确有这个输出,说明原始五城市 CSV 中至少有一部分是小时级记录。我自己做的时候会把datetime的粒度先打印出来确认,不要凭感觉认为是小时级。
2.3 气象要素相关性:分箱而不是直接算相关系数
项目里输出了一组“pm2.5与温度/湿度/气压/露点之间的关系.csv”,很多人看到的第一反应是直接df[['pm25', 'temperature']].corr()。这个做法在这个场景下不推荐,原因是 PM2.5 浓度和气象要素之间不是简单线性关系,温度零上和零下时影响方向完全不同,直接算相关系数会得到接近 0 的结果,画出来也只是一团散点。
常见做法是把温度(或湿度、气压、露点)做分箱处理,按箱计算 PM2.5 均值,得到一条趋势线:
bins = [-20, -10, 0, 10, 20, 30, 40] labels = ['< -10', '-10~0', '0~10', '10~20', '20~30', '> 30'] df['temp_bin'] = pd.cut(df['temperature'], bins=bins, labels=labels, right=False) temp_stat = (df.groupby('temp_bin', observed=True)['pm25'] .agg(['mean', 'count']) .reset_index()) temp_stat.to_csv('pm2.5与温度之间的关系.csv', index=False, encoding='utf-8-sig')pd.cut是分箱的核心函数,bins定义了分界点,right=False表示区间是左闭右开,-10~0这个区间只包含 -10 度但不包含 0 度。observed=True在 pandas 2.x 中是可选参数,在分组时避免未来警告。这样得到的 CSV 就是前端 Echarts 画折线图的数据源,横轴是温度区间,纵轴是该区间内的平均浓度。
湿度、气压、露点这三组字段做同样的处理,只是修改分箱边界。露点与温度高度相关,处理时最好用露点差或者直接沿用同一套区间,不要单独发明一种分箱逻辑。项目文件里同时存在“pm2.5与大气压之间的关系.csv”和“pm2.5与露点之间的关系.csv”,说明作者为每个气象要素都单独算了一份,而不是在一张表里塞四列。保持这种“一个主题一张表”的结构,对后续 Django 读取和前端渲染更友好。
2.4 季度逐年与时间序列:给历史趋势留出数据源
“不同季度逐年数据.csv”和“时间序列逐年数据.csv”这两张表服务于两类图:季度柱状图和年度趋势折线图。分组键要提取两个字段,季度是year + quarter,时间序列是year + month:
df['year'] = df['datetime'].dt.year df['quarter'] = df['datetime'].dt.quarter quarter_stat = df.groupby(['city', 'year', 'quarter'])['pm25'].mean().reset_index() quarter_stat['period'] = quarter_stat['year'].astype(str) + 'Q' + quarter_stat['quarter'].astype(str) monthly_stat = df.groupby(['city', 'year', 'month'])['pm25'].mean().reset_index() monthly_stat['period'] = monthly_stat['year'].astype(str) + '-' + monthly_stat['month'].astype(str).str.zfill(2)注意这里把 city 加进了分组键,因为前端需要按城市切换或叠加多城市曲线。period这一列是在拼接时间标签,str.zfill(2)把7补成07,保证字符串排序时和真实时间顺序一致。如果直接用整数2020-7这种格式,Echarts 的类目轴会按字典序排列,7 月会排在 10 月后面,图表错位。这里的年份字符串和月份补零,是保证 x 轴顺序正确的关键,项目原代码里大概率也做了类似处理。
3. Django 2.x 与 MySQL:模型设计、数据导入与JSON接口
3.1 为什么不用 SQLite 而选 MySQL
源码包里带着 db129.sql,说明原项目最终数据落在 MySQL 里。对毕业设计来说,这个选择本身就是一个加分项:SQLite 是文件型数据库,不需要安装服务端,演示时看不出对数据库设计能力的掌握;MySQL 需要先建库、导入 SQL、配置连接,整条链路更贴近企业开发。而且题目里强调了“数据库”,用 MySQL 完成数据存储,答辩时能讲的内容也更多。
进入代码后,先在 models.py 里定义两张表。一张存五城市逐条原始记录,另一张存分析结果。分两张表的原因:原始记录适合做条件筛选和图表下钻,分析结果适合直接返回给 Echarts,避免每次页面请求都现场计算:
from django.db import models class Pm25Record(models.Model): city = models.CharField(max_length=16, db_index=True) collect_time = models.DateTimeField(db_index=True) pm25 = models.FloatField() temperature = models.FloatField(null=True, blank=True) humidity = models.FloatField(null=True, blank=True) pressure = models.FloatField(null=True, blank=True) dew_point = models.FloatField(null=True, blank=True) wind_direction = models.CharField(max_length=16, blank=True) class Meta: db_table = 'pm25_record' ordering = ['collect_time'] class AnalysisResult(models.Model): module = models.CharField(max_length=32, db_index=True) city = models.CharField(max_length=16, blank=True, default='') x_label = models.CharField(max_length=32) y_value = models.FloatField() class Meta: db_table = 'analysis_result'db_index=True加在城市和时间字段上,是因为页面的多城市对比查询会频繁以city作为过滤条件,MySQL 在数据量上来后没有索引会走全表扫描。AnalysisResult.module存的是图表的业务标识,比如一年中各个月份变化,前端请求时直接filter(module=...),后端的查询逻辑可以保持通用。
3.2 settings.py 数据库配置与 mysqlclient 安装
用 MySQL 就必须在 settings.py 里改数据库配置。Django 的默认配置是 SQLite,改成 MySQL 后还需要安装驱动。Python 3.7 环境下最常用的是 mysqlclient:
DATABASES = { 'default': { 'ENGINE': 'django.db.backends.mysql', 'NAME': 'db129', 'USER': 'root', 'PASSWORD': '你的密码', 'HOST': '127.0.0.1', 'PORT': '3306', 'OPTIONS': {'charset': 'utf8mb4'}, } }NAME对应 db129.sql 导入后创建的数据库名,不是表名。utf8mb4比utf8多支持 emoji 和生僻字,空气质量数据里不会出现 emoji,但建议保留这个配置,避免后续导入其他数据时编码报错。Windows 上pip install mysqlclient经常失败,常见解决办法是下载与 Python 3.7 对应的.whl文件离线安装,或者把ENGINE换成django.db.backends.mysql后搭配 PyMySQL,在__init__.py里加上pymysql.install_as_MySQLdb()。Linux 下装 mysqlclient 则要先装libmysqlclient-dev,这也是项目说明文档里应该写但没有写清的一步。
3.3 导入 db129.sql 与分析结果 CSV
数据库建好后,先把原有 SQL 导入:
mysql -u root -p db129 < db129.sql注意命令里的db129是目标数据库名,前提是已经提前执行过CREATE DATABASE db129 CHARACTER SET utf8mb4。如果直接导入报 “Unknown database”,先建库再导入。导入成功后可以用mysql -u root -p -e "SHOW TABLES FROM db129"验证表是否存在。
导入完成后,还需要把 get_data 目录下的分析结果 CSV 写入AnalysisResult表。这步可以用 Django shell 执行一段独立脚本,也可以写成 get_data/data_import.py:
import os import django os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'untitled.settings') django.setup() import pandas as pd from app01.models import AnalysisResult AnalysisResult.objects.all().delete() csv_files = [ '一年中各个月份变化.csv', '一天中不同时段.csv', 'pm2.5与温度之间的关系.csv', 'pm2.5与相对湿度之间的关系.csv', ] for fname in csv_files: df = pd.read_csv(fname, encoding='utf-8-sig') module = fname.replace('.csv', '') rows = [] for _, row in df.iterrows(): rows.append(AnalysisResult( module=module, city=row.get('city', ''), x_label=str(row.iloc[1]), y_value=row.iloc[2] )) AnalysisResult.objects.bulk_create(rows)AnalysisResult.objects.all().delete()清空旧数据,防止重复执行脚本导致数据翻倍。bulk_create批量写入比create()循环快很多,五张表的数据量不大,但养成批量写入的习惯能避免以后处理几十万条记录时卡死。row.iloc[1]和row.iloc[2]分别取第二列和第三列,对应分析 CSV 中的标签列和数值列。如果 CSV 列顺序不对,这里要按实际表头调整,不能写死。
3.4 views 层输出 JSON:一个接口处理所有图表
前端要画图,后端就要提供数据接口。最直接的方式是写一个通用接口,接收module和city两个 GET 参数,查表后返回 JSON:
from django.http import JsonResponse from django.views.decorators.http import require_GET from .models import AnalysisResult @require_GET def chart_data(request): module = request.GET.get('module', '') city = request.GET.get('city', '') qs = AnalysisResult.objects.filter(module=module) if city: qs = qs.filter(city=city) data = [{'x': r.x_label, 'y': r.y_value} for r in qs] return JsonResponse({'code': 0, 'data': data})require_GET限制这个方法只接受 GET 请求。如果模块名不存在,查询会返回空列表,前端不会报错但会画出一张空图,所以接口里最好再做一层校验,返回code=1和错误提示。注意这里没有用 Django REST Framework,因为项目只需要只读查询,原生 JsonResponse 足够,减少依赖也能降低部署出错的概率。URL 配置里把路径绑定到chart_data即可,不用设计复杂路由。
4. Echarts数据可视化前端:Django模板里的图表渲染
4.1 Django模板引入Echarts静态资源
前端页面在 templates/index.html 中,Echarts 的库文件需要放到静态文件目录。Django 默认的静态文件查找路径是各 app 下的 static 目录,app01/static/echarts.min.js。模板里的引入方式要使用 Django 模板引擎提供的 static 标签:
{% load static %} <!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>PM2.5 数据分析</title> <script src="{% static 'echarts.min.js' %}"></script> </head> <body> <div id="month-chart" style="width: 90%; height: 420px"></div> <script src="{% static 'main.js' %}"></script> </body> </html>用{% static %}标签而不是硬编码/static/echarts.min.js,是因为 Django 项目部署到子路径或者使用 CDN 时,静态文件 URL 会变。开发阶段static标签可以正常工作,部署时再执行collectstatic收集所有静态文件即可。main.js里写图表初始化逻辑,不建议在 HTML 里写大量 script,不利于调试和维护。
4.2 fetch 请求接口并渲染折线图
前端拿到后端 JSON 后,用 Echarts 初始化图表。这里用原生 fetch 而不是 jQuery 的$.ajax,因为 Echarts 5.x 和现代浏览器已经完全支持 fetch,多一个 jQuery 依赖没有意义:
async function loadTrendChart() { const resp = await fetch('/chart_data/?module=一年中各个月份变化'); const result = await resp.json(); const chart = echarts.init(document.getElementById('month-chart')); const option = { title: { text: 'PM2.5 各月份均值变化' }, tooltip: { trigger: 'axis' }, legend: { data: ['北京', '上海', '广州'] }, grid: { left: '10%', right: '4%', bottom: '10%', containLabel: true }, xAxis: { type: 'category', name: '月份' }, yAxis: { type: 'value', name: 'μg/m³' }, series: [] }; chart.setOption(option); } loadTrendChart();这里的 xAxis 和 series 还没有数据。因为后端返回的数据是单城市,而目标页面的图表通常是五城市对比,所以更好的做法是先按城市维度拉取五组 JSON,再动态构造 series。下面这个写法把 fetch 循环和 series 构造结合到一起:
const cities = ['北京', '上海', '广州', '成都', '沈阳']; async function buildSeries(module) { const series = []; for (const city of cities) { const resp = await fetch(`/chart_data/?module=${encodeURIComponent(module)}&city=${encodeURIComponent(city)}`); const result = await resp.json(); if (result.code !== 0) continue; series.push({ name: city, type: 'line', smooth: true, data: result.data.map(row => [row.x, row.y]) }); } return series; }encodeURIComponent用来编码中文参数,直接拼接 URL 时中文参数在某些浏览器中会出错。data这里用[row.x, row.y]这种二维数组格式,Echarts 会自动识别为坐标对,比传两个数组更直观。接口返回的数据是按表里的顺序排的,但前端不能依赖这个顺序,尤其是 x 轴是类目轴时,更稳妥的做法是在后端查询里明确order_by('id')或order_by('x_label')。
4.3 图表配置项与常见坑
Echarts 图表不出图,七成问题出在数据格式,两成出在容器高度,一成出在配置项名称。Django 模板里的图表容器如果高度为 0,初始化时不会报错,但图表就是不显示。样式表里要确保容器有明确高度,比如height: 420px,不能只给宽度。
下面是本项目几类图表推荐用的配置:
| 图表类型 | 核心配置 | 说明 |
|---|---|---|
| 月度趋势折线图 | xAxis.type: 'category'+series[i].type: 'line' | x 轴是月份字符串,适合用类目轴 |
| 时段变化折线图 | smooth: true | 平滑曲线展示昼夜变化,避免锯齿 |
| 气象要素关系图 | tooltip.trigger: 'axis' | 鼠标悬停时显示温度区间与浓度 |
| 季度柱状图 | series[i].type: 'bar' | 柱状图更适合季度对比,视觉差异明显 |
| 时间序列总图 | dataZoom组件 | 6 年数据较长,加 dataZoom 拖动查看 |
Echarts 5 中散点图、折线图、柱状图的 data 数组格式不统一,传错后会出空白。比如折线图支持[value, value]二维数组,柱状图则常直接传[value1, value2]。把 PM2.5 浓度做成二维坐标对时,一定要确认 series 类型,否则会出现“有数据但画不出来”的假死状态。遇到这种情况,先在浏览器控制台打印result.data,看看每一行的字段名是不是x和y,再检查row.x是否为空字符串。
4.4 动态切换模块的前端组织方式
页面如果只有一张图不符合“可视化分析”的定位,项目里应该有多个图表或一个联动切换的图表面板。常见做法是给模块按钮绑定点击事件,每次点击时重新调用buildSeries并更新图表:
document.querySelectorAll('.module-btn').forEach(btn => { btn.addEventListener('click', async () => { const module = btn.dataset.module; const series = await buildSeries(module); chart.setOption({ series: series }); }); });dataset.module对应按钮上的>import pymysql pymysql.install_as_MySQLdb()
这样 Django 的 MySQL 后端会调用 PyMySQL 作为数据库驱动,功能上完全兼容。项目文件里带了__init__.py,建议把这段写进工程主目录的__init__.py而不是 app 的__init__.py,因为启动时加载顺序更早,不会出现 DSN 未初始化的问题。
5.3 从数据结构上扩展:给项目加一个污染等级模块
原项目只做均值分析,答辩时要体现扩展能力,可以在不改变现有框架的情况下增加一列“污染等级”。PM2.5 浓度分级有一个通用标准:35μg/m³ 以下为优,35-75 为良,75-115 为轻度污染,115-150 中度,150-250 重度,250 以上严重。利用 pandas 的pd.cut把浓度分箱,统计每个等级的天数占比,再走一遍“CSV 导出—数据导入—Django 查询—Echarts 柱状图”的链路,等于把整个开发流程又演示了一遍。这个模块不算复杂,但能体现你理解了整个前后端数据流,比单纯多画一张图更有说服力。
再加一步,把五个城市的等级占比数据合并输出成一张汇总表,前端用堆叠柱状图展示,就能看到北京、沈阳的冬季重污染天数明显高于广州、成都,这个结论和公开研究一致,答辩时能形成一个小亮点。顺便可以让图表支持点击柱子下钻到具体污染事件,也就是先加载月度数据,点击月份后再请求该月的逐日数据,复用现有接口多加一个level参数就能实现。整个页面仍然保持一个模块一张图,不会破坏现有设计。
本文还有配套的精品资源,点击获取