Python数据分析实战:从CSV到空气质量可视化趋势图
简介面向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, nrows5, encodingutf-8-sig ) print(df_preview.head()) print(df_preview.columns)这里encodingutf-8-sig是为了处理带 BOM 的 CSV。Windows 环境下 pandas 或 Excel 导出的文件经常带 BOM直接用utf-8会导致第一列列名变成\ufeff日期。如果报 UnicodeDecodeError再换成encodinggbk试一次。nrows5只读 5 行文件大的时候先看结构再决定解析参数。字段名也可能不统一。比如有的文件叫日期有的叫datePM2.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(columnsrename_map, inplaceTrue) return dfrename_map把多组异名映射到统一字段。注意.strip().lower()会先把列名两侧空格和大小写问题处理掉这样PM2.5能变成pm2.5再被映射成pm25。下面的表格是这份数据里最可能出现的字段对照原始 CSV 列名统一后字段pandas 类型date / 日期 / 时间datedatetime64[ns]AQI / AQI指数aqifloat64PM2.5 / PM2_5pm25float64城市没有就用文件名注入citystr2.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 / fair_{city}_2018.csv df pd.read_csv(path, encodingutf-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_indexTrue) air_all[date] pd.to_datetime(air_all[date]) print(air_all.groupby(city).size())concat时不传ignore_indexTrue行索引会原样保留后续resample容易出现重复索引。传上ignore_indexTrue后重新生成 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, centerTrue, min_periods2) .median() )rolling(7, centerTrue)代表窗口包含当天前后各 3 天min_periods2允许边界只有 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], labelcity.upper(), linewidth0.8, alpha0.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(ncol4, fontsize9, frameonFalse) fig.tight_layout() plt.savefig(aqi_trend_2018.png, dpi200) plt.show()MonthLocator()让横轴按月份分布刻度避免 365 个日期标签挤在一起。linewidth0.8是因为全年逐日数据线条本身已经足够密集太粗容易互相遮挡。alpha0.8给四条线留一点透明度叠加空间。保存 PNG 时dpi200能保证放进 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, labelAQI, lw1) ax2.plot(df_sc[date], df_sc[pm25], color#1f77b4, labelPM2.5, lw1) 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, ascendingFalse) ) print(summary)这里good_ratio用 lambda 在聚合同时算出优良天数占比避免先生成布尔列再分组求和。需要给报表展示时可以把 DataFrame 转成 markdown 表格或 Excelsummary.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()而不是直接除以 30monthly ( 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(indexmonth, columnscity, valuesgood_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( indexcity, columnsseason, valuespm25, aggfuncmean ).round(1) print(season_pivot)pivot_table默认聚合函数是mean这里显式声明aggfuncmean并有round(1)控制显示精度。输出的行是城市列是季节值是该季节 PM2.5 均值。用 seaborn 画热力图比手写颜色映射快很多import seaborn as sns plt.figure(figsize(6, 4)) sns.heatmap( season_pivot, annotTrue, fmt.1f, cmapYlOrRd, cbar_kws{label: PM2.5 (µg/m³)} ) plt.title(2018 年 PM2.5 季节均值热力图) plt.tight_layout() plt.savefig(season_pm25_heatmap.png, dpi200) plt.show()annotTrue在格子上显示数值fmt.1f控制显示一位小数cmapYlOrRd从黄色到红色渐变便于快速识别高值。热力图的横轴顺序取决于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( indexmonth, columnslevel, valuesaqi, aggfunccount, fill_value0 ) level_pivot.plot(kindbar, stackedTrue, figsize(10, 5)) plt.title(2018 年空气质量等级逐月堆积图) plt.tight_layout() plt.show()aqi_level函数返回中文等级pivot_table用count统计每个等级的频数fill_value0避免空月份出现 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.alpha0.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, figurefig, hspace0.3, wspace0.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 北上广深空气质量分析看板, fontsize16)加总标题plt.savefig(dashboard.png, dpi200)输出大图。这就是一个不需要前端框架的“轻量数据可视化大屏”适合内部报表和答辩展示。最后再说一个导出小技巧如果最终要放到网页或者开源文档里优先保存 SVG 而不是 PNG。SVG 是矢量图放大不失真且文件体积小plt.savefig(dashboard.svg, formatsvg)换用 SVG 后可以在浏览器里直接右键保存也可以被 Inkscape 继续编辑颜色。整套脚本里的城市列表、文件路径、配色都定义在顶部常量里下次要把广州换成杭州只需要改CITIES和文件名规则剩余部分不用动。本文还有配套的精品资源点击获取