简介:面向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天气/*.csvwc -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.5、PM2_5或pm25。我习惯先统一为小写,再重命名:
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 dfrename_map把多组异名映射到统一字段。注意.strip().lower()会先把列名两侧空格和大小写问题处理掉,这样PM2.5能变成pm2.5,再被映射成pm25。下面的表格是这份数据里最可能出现的字段对照:
| 原始 CSV 列名 | 统一后字段 | pandas 类型 |
|---|---|---|
| date / 日期 / 时间 | date | datetime64[ns] |
| AQI / AQI指数 | aqi | float64 |
| PM2.5 / PM2_5 | pm25 | float64 |
| 城市(没有就用文件名注入) | city | str |
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_index再groupby的冗余。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 年度均值排序与可视化呈现
画图之外,还要输出统计表。用groupby和agg一次算多个指标:
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,方便后面用sum和mean做天数和占比计算。
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_table用count统计每个等级的频数,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"] = Falseaxes.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和文件名规则,剩余部分不用动。
本文还有配套的精品资源,点击获取