Python数据分析实战:从CSV到空气质量可视化趋势图
2026/9/13 15:27:54 网站建设 项目流程

简介:面向Python数据处理与可视化学习者,聚焦2018年北京、上海、广州、深圳四市空气质量,围绕AQI与PM2.5的探索性分析展开。压缩包共18个文件,含13个可运行的Python脚本和5个CSV数据文件,脚本覆盖AQI月度/季节趋势、PM2.5热力图及空气质量等级统计等场景,数据无需额外采集即可复现核心图表;整体仅24KB,轻量易用。目前已有3988人学习下载。读者可从中获得真实城市空气数据的清洗、聚合与可视化完整流程,掌握pandas、matplotlib、seaborn的实际用法,并学会用时间序列图、饼图/柱状图展示优良天气占比与季节规律,为环保数据分析和相关报告产出提供可直接改造的模板。

1. 用 Python 拆解北上广深空气质量数据:从 CSV 到可复现的趋势图

如果你手头正好有一份 2018 年北上广深四个城市的逐日空气质量 CSV,第一反应可能是用 Excel 拉一张折线图。可一旦要处理四张表、统一字段、按月汇总,Excel 的操作成本会成倍上升。这个项目看起来只是“读 CSV、画曲线”,实际覆盖了数据分析最常用的完整链路:多表合并、日期索引、聚合统计、图表定制。压缩包里除了北上广深四座城市的 2018 年数据,还附带了一批天津 2017 年的分析脚本,把 AQI 月均、季节变化、质量等级拆成了独立脚本,方便直接迁移。适合想用真实数据练手 Python 数据处理和可视化的开发者,也适合需要快速出周报、月报的分析师。

2. 多城市 CSV 的加载与预处理:pandas 怎么处理字段不齐的表格

2.1 先摸清文件结构:read_csv 前的三个关键参数

一份来自压缩包的数据,不能上来就pd.read_csv然后画图。常见问题是字段名不一致、日期格式不统一、PM2.5 列是字符串。我的习惯是先解压,再看文件大小和行数:

unzip 2018天气.zip ls -lh 2018天气/ wc -l 2018天气/*.csv

wc -l能快速看出哪些文件行数异常。366 行是闰年逐日记录,367 行说明多一个表头或者空行,372 行说明有重复记录或多余空行。如果四个城市行数不一致,合并时要小心对齐问题。

然后在 Python 中读一个文件的前几行,确认列名和编码:

import pandas as pd df_preview = pd.read_csv( "2018天气/air_beijing_2018.csv", nrows=5, encoding="utf-8-sig" ) print(df_preview.head()) print(df_preview.columns)

这里encoding="utf-8-sig"是为了处理带 BOM 的 CSV。Windows 环境下 pandas 或 Excel 导出的文件经常带 BOM,直接用utf-8会导致第一列列名变成\ufeff日期。如果报 UnicodeDecodeError,再换成encoding="gbk"试一次。nrows=5只读 5 行,文件大的时候先看结构再决定解析参数。

字段名也可能不统一。比如有的文件叫日期,有的叫date;PM2.5 一列可能是PM2.5PM2_5pm25。我习惯先统一为小写,再重命名:

def normalize_columns(df): df.columns = [c.strip().lower() for c in df.columns] rename_map = { "date": "date", "日期": "date", "aqi": "aqi", "aqi值": "aqi", "aqi指数": "aqi", "pm2.5": "pm25", "pm2_5": "pm25", "pm25": "pm25", "pm": "pm25" } df.rename(columns=rename_map, inplace=True) return df

rename_map把多组异名映射到统一字段。注意.strip().lower()会先把列名两侧空格和大小写问题处理掉,这样PM2.5能变成pm2.5,再被映射成pm25。下面的表格是这份数据里最可能出现的字段对照:

原始 CSV 列名统一后字段pandas 类型
date / 日期 / 时间datedatetime64[ns]
AQI / AQI指数aqifloat64
PM2.5 / PM2_5pm25float64
城市(没有就用文件名注入)citystr

2.2 合并四个城市:concat 之后先检查索引

有了统一的列名,就可以写一个加载函数,把四个城市读进来拼成一张总表:

from pathlib import Path DATA_DIR = Path("2018天气") CITIES = ["beijing", "shanghai", "guangzhou", "shenzhen"] def load_city(city: str) -> pd.DataFrame: path = DATA_DIR / f"air_{city}_2018.csv" df = pd.read_csv(path, encoding="utf-8-sig") df = normalize_columns(df) df["city"] = city # 从文件名注入城市名 return df[["city", "date", "aqi", "pm25"]] air_all = pd.concat([load_city(c) for c in CITIES], ignore_index=True) air_all["date"] = pd.to_datetime(air_all["date"]) print(air_all.groupby("city").size())

concat时不传ignore_index=True,行索引会原样保留,后续resample容易出现重复索引。传上ignore_index=True后重新生成 0..N-1 的索引,后面按城市分组时也干净。pd.to_datetime把日期列变成时间类型后,才能做按月的重采样和季节划分。

2.3 缺失值与异常值:不要一上来就 dropna

看缺失值分布和基础统计:

print(air_all.isna().sum()) print(air_all.groupby("city")[["aqi", "pm25"]].describe().round(1))

如果缺失量小于 5%,我一般用滚动窗口中位数填充。空气质量数据受短期气象影响大,用前向填充会把昨天的浓度直接搬过来,等于人为制造连续同值;用前后三天中位数更平滑,也更符合“当天数据缺失但与本周浓度趋势有关”的假设。

for city in CITIES: city_mask = air_all["city"] == city air_all.loc[city_mask, "pm25"] = ( air_all.loc[city_mask, "pm25"] .rolling(7, center=True, min_periods=2) .median() )

rolling(7, center=True)代表窗口包含当天前后各 3 天;min_periods=2允许边界只有 2 个有效值就能算。AQI 列如果出现大于 500 的值,要分情况看:可能是沙尘暴期间的极端值,也可能是传感器故障。可以先保留并标注,不要直接删,因为极端污染峰值恰恰是年度分析里最值得注意的部分。

3. AQI 与 PM2.5 年度趋势:matplotlib 折线图与双轴对比

3.1 为什么选折线图而不是散点图

AQI 和 PM2.5 都是随着时间变化的序列。折线图能直接展示“哪个时间段持续走高”“夏季这段为什么平缓”,散点图更适合做两列变量的相关性分析,放在时间轴上反而看不出连续变化。四个城市合并后先画在一张图里,能快速看出城市之间的基线差异和峰谷错位。

3.2 全年 AQI 折线图基础版

用 matplotlib 直接绘制,需要先设置中文字体,否则标题和图例会变成方块:

import matplotlib.pyplot as plt import matplotlib.dates as mdates plt.rcParams["font.sans-serif"] = ["SimHei", "Arial Unicode MS"] plt.rcParams["axes.unicode_minus"] = False fig, ax = plt.subplots(figsize=(12, 5)) for city in CITIES: df_city = air_all[air_all["city"] == city].sort_values("date") ax.plot(df_city["date"], df_city["aqi"], label=city.upper(), linewidth=0.8, alpha=0.8) ax.xaxis.set_major_locator(mdates.MonthLocator()) ax.xaxis.set_major_formatter(mdates.DateFormatter("%m月")) ax.set_xlabel("日期") ax.set_ylabel("AQI") ax.set_title("2018 年北上广深 AQI 年度趋势") ax.legend(ncol=4, fontsize=9, frameon=False) fig.tight_layout() plt.savefig("aqi_trend_2018.png", dpi=200) plt.show()

MonthLocator()让横轴按月份分布刻度,避免 365 个日期标签挤在一起。linewidth=0.8是因为全年逐日数据线条本身已经足够密集,太粗容易互相遮挡。alpha=0.8给四条线留一点透明度叠加空间。保存 PNG 时dpi=200能保证放进 PPT 或报告里不虚。

如果只想突出四条线的相对走势,可以设置每日数据为细线、月度均值加粗覆盖。常见做法是先把日度数据算成月均值:

monthly_aqi = ( air_all.set_index("date") .groupby("city")["aqi"] .resample("M") .mean() .reset_index() )

groupby后直接resample("M"),能省去先set_indexgroupby的冗余。reset_index()把 city 和月份从行索引拉回列,方便继续绘图或导出。

3.3 双轴图:AQI 与 PM2.5 不在同一量纲

AQI 是一个无量纲指数,PM2.5 是浓度(µg/m³),两者画在同一坐标系里会互相压制。常见做法是用twinx()建第二条纵轴:

fig, ax1 = plt.subplots(figsize=(12, 5)) ax2 = ax1.twinx() df_sc = air_all[air_all["city"] == "shanghai"].sort_values("date") ax1.plot(df_sc["date"], df_sc["aqi"], color="#d62728", label="AQI", lw=1) ax2.plot(df_sc["date"], df_sc["pm25"], color="#1f77b4", label="PM2.5", lw=1) ax1.set_ylabel("AQI") ax2.set_ylabel("PM2.5 (µg/m³)") ax1.set_title("上海 2018 年 AQI 与 PM2.5 走势") ax1.xaxis.set_major_locator(mdates.MonthLocator()) ax1.xaxis.set_major_formatter(mdates.DateFormatter("%m月")) fig.tight_layout() plt.show()

在双轴图上观察两条线是否“同涨同跌”要有判断依据,不能只靠肉眼看。可以用 pandas 直接算相关系数:

corr = df_sc[["aqi", "pm25"]].corr().iloc[0, 1] print(f"上海 AQI 与 PM2.5 的相关系数: {corr:.2f}")

AQI 由 PM2.5 等多种污染物综合得到,在一部分城市二者相关系数可能超过 0.9,但在臭氧污染严重的夏季会出现 AQI 高、PM2.5 低的反向情况。这是理解城市污染类型的入口。

3.4 年度均值排序与可视化呈现

画图之外,还要输出统计表。用groupbyagg一次算多个指标:

summary = ( air_all.groupby("city") .agg(aqi_mean=("aqi", "mean"), pm25_mean=("pm25", "mean"), good_ratio=("aqi", lambda x: (x <= 100).mean())) .round(1) .sort_values("good_ratio", ascending=False) ) print(summary)

这里good_ratio用 lambda 在聚合同时算出优良天数占比,避免先生成布尔列再分组求和。需要给报表展示时,可以把 DataFrame 转成 markdown 表格或 Excel:

summary.to_markdown("air_quality_summary.md") summary.to_excel("air_quality_summary.xlsx")

to_markdown需要安装tabulate,适合直接贴到内部技术博客;to_excel需要openpyxl,注意先导入 pandas 和 openpyxl 环境。

4. 优良天气占比与季节特征:从日数据到月度聚合

4.1 优良天气阈值:75 还是 100

摘要描述里提到“AQI 小于 75 算优良”,但按中国 HJ 633-2012 环境空气质量指数技术规定,AQI 0-50 为优,51-100 为良,优良天气应取AQI <= 100。实际业务里有的地方用 75 这个值做内部考核,是为了更严格地倒逼减排。代码中应该把阈值定义成常量,后续换口径只改一行:

GOOD_AQI = 100 # 按国标取 100;若平台要求 75,改成 75 即可 air_all["good"] = (air_all["aqi"] <= GOOD_AQI).astype(int)

留下的布尔列转成 int,方便后面用summean做天数和占比计算。

4.2 月度优良天数占比:resample 的正确写法

月度占比需要先按月分组,再对good列求和,并除以当月总天数。注意 2018 年不是闰年,月份天数固定,但为了代码通用,建议用count()而不是直接除以 30:

monthly = ( air_all.set_index("date") .groupby("city")["good"] .resample("M") .agg(["sum", "count"]) ) monthly["good_ratio"] = monthly["sum"] / monthly["count"] * 100 monthly = monthly.rename(columns={"sum": "good_days"}) print(monthly.head(12))

resample("M")会把日期索引按自然月切段。groupby("city")["good"]后接resample("M")需要把date列先设为索引,否则 pandas 会报错。最终每一行是每个城市每个月的优良天数、当月总天数和占比。

如果要跨城市横向对比,把索引转回普通列:

monthly = monthly.reset_index() monthly["month"] = monthly["date"].dt.month pivot_monthly = monthly.pivot(index="month", columns="city", values="good_ratio")

dt.month提取月份数字后,pivot把城市展开成列,行就是 1-12 月,方便直接画分组柱状图。

4.3 季节划分与 PM2.5 热力图

年度图只能看到宏观走势,季节规律需要人工分组。用月份映射到季节:

season_map = { 12: "冬季", 1: "冬季", 2: "冬季", 3: "春季", 4: "春季", 5: "春季", 6: "夏季", 7: "夏季", 8: "夏季", 9: "秋季", 10: "秋季", 11: "秋季" } air_all["season"] = air_all["date"].dt.month.map(season_map) season_pivot = air_all.pivot_table( index="city", columns="season", values="pm25", aggfunc="mean" ).round(1) print(season_pivot)

pivot_table默认聚合函数是mean,这里显式声明aggfunc="mean",并有round(1)控制显示精度。输出的行是城市,列是季节,值是该季节 PM2.5 均值。用 seaborn 画热力图,比手写颜色映射快很多:

import seaborn as sns plt.figure(figsize=(6, 4)) sns.heatmap( season_pivot, annot=True, fmt=".1f", cmap="YlOrRd", cbar_kws={"label": "PM2.5 (µg/m³)"} ) plt.title("2018 年 PM2.5 季节均值热力图") plt.tight_layout() plt.savefig("season_pm25_heatmap.png", dpi=200) plt.show()

annot=True在格子上显示数值,fmt=".1f"控制显示一位小数,cmap="YlOrRd"从黄色到红色渐变,便于快速识别高值。热力图的横轴顺序取决于season_pivot的列顺序,如果文件里没有自然顺序,可以在pivot_table后用reindex(columns=["春季", "夏季", "秋季", "冬季"])指定顺序。

以上就形成了一个从日数据到季节的二级下钻。如果还想观察每个月各城市空气质量等级分布,可以把 AQI 级别做成堆积柱状图:

def aqi_level(aqi): if aqi <= 50: return "优" if aqi <= 100: return "良" if aqi <= 150: return "轻" if aqi <= 200: return "中" if aqi <= 300: return "重" return "严" air_all["level"] = air_all["aqi"].apply(aqi_level) level_pivot = air_all.pivot_table( index="month", columns="level", values="aqi", aggfunc="count", fill_value=0 ) level_pivot.plot(kind="bar", stacked=True, figsize=(10, 5)) plt.title("2018 年空气质量等级逐月堆积图") plt.tight_layout() plt.show()

aqi_level函数返回中文等级,pivot_tablecount统计每个等级的频数,fill_value=0避免空月份出现 NaN 导致堆积图中断。

5. 把可视化脚本封装成可复用模板:从单图到多图看板

5.1 统一样式:让所有图保持同一套 RC 参数

项目里面的脚本分散在多个 .py 里,如果每张图的字体、网格、分辨率都不一样,拼接成报告时会显得凌乱。我一般会做一个style.py,把 matplotlib 的 RC 参数统一:

import matplotlib as mpl def apply_style(): mpl.rcParams["figure.dpi"] = 150 mpl.rcParams["font.sans-serif"] = ["SimHei", "Arial Unicode MS"] mpl.rcParams["axes.unicode_minus"] = False mpl.rcParams["axes.grid"] = True mpl.rcParams["grid.alpha"] = 0.3 mpl.rcParams["axes.spines.top"] = False mpl.rcParams["axes.spines.right"] = False

axes.grid打开背景网格,grid.alpha=0.3降低网格线干扰,axes.spines.top/right去掉上侧和右侧的边框,视觉上更接近现代报表风格。调用apply_style()后再执行绘图脚本,每张图的字体和网格风格就一致了。

5.2 用 GridSpec 拼一张“数据可视化大屏”风格总图

单张图表适合快速验证,但给团队看周报时需要把趋势图、占比图、热力图拼在一张大图里。用GridSpec控制子图占位,比plt.subplots更灵活:

from matplotlib.gridspec import GridSpec fig = plt.figure(figsize=(16, 9)) gs = GridSpec(2, 2, figure=fig, hspace=0.3, wspace=0.2) ax_trend = fig.add_subplot(gs[0, :]) # 第一行整行放趋势 ax_ratio = fig.add_subplot(gs[1, 0]) # 左下放占比 ax_heat = fig.add_subplot(gs[1, 1]) # 右下放热力图

gs[0, :]表示第一行跨两列,适合放全年 AQI 趋势折线图。左下和右下分别放优良占比柱状图和 PM2.5 季节热力图。画完后用fig.suptitle("2018 北上广深空气质量分析看板", fontsize=16)加总标题,plt.savefig("dashboard.png", dpi=200)输出大图。这就是一个不需要前端框架的“轻量数据可视化大屏”,适合内部报表和答辩展示。

最后再说一个导出小技巧:如果最终要放到网页或者开源文档里,优先保存 SVG 而不是 PNG。SVG 是矢量图,放大不失真,且文件体积小:

plt.savefig("dashboard.svg", format="svg")

换用 SVG 后,可以在浏览器里直接右键保存,也可以被 Inkscape 继续编辑颜色。整套脚本里的城市列表、文件路径、配色都定义在顶部常量里,下次要把广州换成杭州,只需要改CITIES和文件名规则,剩余部分不用动。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询