简介:这份资源面向Python数据分析与可视化初学者及需要完成期末大作业、课程设计的学生,围绕中国多个城市PM2.5值展开完整的数据分析与可视化实践。项目以真实城市空气质量数据为对象,涵盖数据读取、清洗、统计分析与多维度图表展示,帮助读者掌握从原始CSV到可视化HTML页面的完整流程。压缩包共21个文件,约6.13MB,包含1个Python主程序、5个城市PM2.5数据CSV文件、13个可视化结果HTML页面以及2份docx项目报告与数据说明文档,代码附有注释,结构清晰,便于直接部署运行。目前已有1512人学习下载。通过该资源,读者可获得一套可直接参考的课设方案,理解城市PM2.5数据的分析思路与可视化呈现方式,并借助报告文档梳理项目背景、实现过程与结论,适合作为高分大作业模板或数据分析入门练手项目。
1. 从一份 PM2.5 数据说起:这套 Python 分析链路到底能解决什么
手头拿到一份中国城市空气质量数据,字段里有城市、日期、AQI、PM2.5、PM10、SO2、NO2、CO、O3,很多人第一反应是直接df.plot()画条折线就交差。但真正做过环境数据分析的人都知道,PM2.5 这类数据有三个绕不开的麻烦:时间跨度长导致横坐标挤成一团、城市之间量级差异大导致一张图看不出规律、缺失值和异常值混在里面让均值失真。这套「Python数据分析与可视化-中国城市PM2.5值的数据分析及可视化」要解决的,就是从原始 CSV 到能讲清楚结论的图表这一整条链路。它适合两类人:一类是刚学完 pandas 想找一个完整数据分析项目练手的入门者,另一类是需要快速产出空气质量分析报告、又不想每次都从零写清洗脚本的从业者。下面按数据清洗、统计分析、可视化、避坑、进阶的顺序,把这条链路拆开讲透。
2. 数据清洗与预处理:PM2.5 原始表怎么变成能算的表
拿到数据的第一步永远不是画图,而是搞清楚这份表能不能直接算。中国城市空气质量数据常见的来源是各地环保部门公开的日度或小时数据,导出成 CSV 后往往带着一堆问题:列名是中文、日期是字符串、PM2.5 列里混着—或空字符串、部分城市只有几个月的数据。如果跳过清洗直接groupby,得到的结果要么报错,要么算出来的均值是错的,这种错误在报告里非常致命。
2.1 先看清数据结构再动手
我一般会先跑一段探查代码,把行列数、字段类型、缺失比例、日期范围一次性看清楚,而不是上来就read_csv然后直接画图。这一步花两分钟,能省掉后面半小时的排查。
import pandas as pd import numpy as np # 读取原始数据,注意编码,国内公开数据常见 utf-8 或 gbk df = pd.read_csv("city_air_quality.csv", encoding="utf-8") # 基础信息:行数、列名、类型、非空数量 print(df.shape) print(df.dtypes) print(df.isnull().sum()) # 看前几行,确认列名和实际内容是否对得上 print(df.head()) # 日期列如果是字符串,先转成 datetime,方便后续按时间聚合 df["date"] = pd.to_datetime(df["date"], errors="coerce") print(df["date"].min(), df["date"].max())这段代码的关键在errors="coerce",它会把无法解析的日期变成NaT而不是直接抛异常,方便你统计有多少条脏日期。df.isnull().sum()输出的是每一列的缺失数量,PM2.5 列如果缺失超过 20%,就要考虑这个城市或时间段是否值得保留。参数上,encoding要根据实际文件调整,读出来乱码就换gbk再试。
2.2 缺失值、异常值和重复值的处理策略
PM2.5 的缺失值不能一律用均值填充。如果缺失是随机的单点缺失,用前后值插值或均值填充都行;如果是整段缺失,比如某个城市连续一个月没有数据,填充就是在编数据。我的做法是:单点缺失用时间序列插值,连续缺失超过 3 天的直接标记为无效,不参与均值计算。
# 去掉完全重复的行 df = df.drop_duplicates() # PM2.5 转成数值型,非数字变成 NaN df["PM2.5"] = pd.to_numeric(df["PM2.5"], errors="coerce") # 按城市分组,对 PM2.5 做时间序列插值,限制最多填补 2 个连续缺失 df = df.sort_values(["city", "date"]) df["PM2.5"] = df.groupby("city")["PM2.5"].transform( lambda s: s.interpolate(method="linear", limit=2) ) # 异常值处理:PM2.5 为负数或超过 1000 的基本是传感器故障或录入错误 df.loc[(df["PM2.5"] < 0) | (df["PM2.5"] > 1000), "PM2.5"] = np.nan # 统计每个城市有效数据量,太少的城市直接剔除 valid_count = df.groupby("city")["PM2.5"].count() keep_cities = valid_count[valid_count >= 30].index df = df[df["city"].isin(keep_cities)]interpolate的limit=2是控制插值范围,超过两个连续缺失就不补,避免制造虚假数据。异常值阈值 1000 是根据 PM2.5 的物理量级定的,正常污染天也就几百,上千基本是设备问题。最后按城市统计有效样本量,少于 30 条的城市直接去掉,否则算出来的城市均值没有代表性。这一步做完,数据才算真正可用。
3. 统计分析:从城市排名到时间规律怎么算才靠谱
清洗完之后,分析的目标通常有三个:哪些城市污染重、污染的时间规律是什么、不同污染物之间有没有关联。这三个问题对应三种统计手法,不能混在一起用一张图糊弄过去。
3.1 城市维度:均值排名和分布对比
城市排名最忌讳只看均值。均值会被极端污染天拉高,一个城市如果有几天爆表,均值就上去了,但平时可能很干净。所以我会同时算均值、中位数和超标天数占比,三个指标一起看。
# 按城市聚合,算均值、中位数、最大值和有效天数 city_stats = df.groupby("city")["PM2.5"].agg( mean_pm25="mean", median_pm25="median", max_pm25="max", valid_days="count" ).reset_index() # 超标天数:PM2.5 日均值超过 75 微克每立方米算超标 over_days = df[df["PM2.5"] > 75].groupby("city")["date"].nunique() city_stats["over_days"] = city_stats["city"].map(over_days).fillna(0) city_stats["over_ratio"] = city_stats["over_days"] / city_stats["valid_days"] # 按均值排序,取前 15 个城市 city_stats = city_stats.sort_values("mean_pm25", ascending=False) print(city_stats.head(15))agg里一次算多个统计量,比循环 groupby 快很多。超标阈值 75 是常用的日均限值参考,over_ratio反映的是污染频率而不是污染程度,这两个维度结合才能区分「偶尔爆表」和「长期偏高」的城市。如果某个城市均值高但超标比例低,说明它可能有个别极端天;反过来均值中等但超标比例高,说明它长期处在轻度污染状态。
3.2 时间维度:月度趋势和季节规律
PM2.5 的季节性非常明显,北方城市冬季取暖期浓度普遍高于夏季。按月聚合能把这个规律直接看出来,但要注意不同年份的同月数据要分开还是合并,取决于你想看的是长期趋势还是单年规律。
# 提取月份和季节 df["month"] = df["date"].dt.month df["season"] = df["month"].map({ 12: "冬季", 1: "冬季", 2: "冬季", 3: "春季", 4: "春季", 5: "春季", 6: "夏季", 7: "夏季", 8: "夏季", 9: "秋季", 10: "秋季", 11: "秋季" }) # 按月份和城市分组算均值 monthly = df.groupby(["city", "month"])["PM2.5"].mean().reset_index() # 按季节算全国均值 season_avg = df.groupby("season")["PM2.5"].mean().reindex( ["春季", "夏季", "秋季", "冬季"] ) print(season_avg)dt.month提取月份后做映射,比写一堆 if-else 干净。reindex是为了让季节按固定顺序输出,不然 pandas 默认按字母或出现顺序排,图上看不出季节递进。这一步的输出可以直接喂给后面的可视化,也可以先打印出来人工核对一下有没有反常识的结果,比如夏季均值反而最高,那就要回头查数据是不是把南半球城市混进来了。
3.3 污染物相关性:PM2.5 和谁一起升高
PM2.5 不是孤立指标,它和 PM10、SO2、NO2、CO 往往有同源关系。算一下相关系数矩阵,能帮你判断污染是扬尘型还是燃烧型。
# 选取污染物列,算皮尔逊相关系数 pollutants = ["PM2.5", "PM10", "SO2", "NO2", "CO", "O3"] corr = df[pollutants].corr(method="pearson") print(corr["PM2.5"].sort_values(ascending=False))corr默认忽略 NaN,所以清洗后的数据可以直接用。看 PM2.5 那一列,如果和 PM10 相关性极高(0.9 以上),说明当地颗粒物污染以扬尘为主;如果和 SO2、NO2 相关性高,说明工业燃烧和机动车尾气贡献大。这个结论在报告里比单纯说「PM2.5 超标」有价值得多。
4. 可视化落地:用 matplotlib 和 pyecharts 把结论画出来
分析结果如果只停在表格里,报告的说服力会打对折。可视化要解决的核心问题是:让读者三秒内看出哪个城市重、哪个季节高、哪种污染物相关。这一章给两套方案,matplotlib 适合出静态报告图,pyecharts 适合做可交互的网页图表。
4.1 城市排名条形图:横坐标太密集怎么破
城市排名图最常见的问题就是城市名太长导致横坐标挤成一团,这也是很多人搜「python画图横坐标太密集」的原因。解决办法有两个:横向条形图让城市名竖着排,或者只画 Top 15 而不是全部城市。
import matplotlib.pyplot as plt import matplotlib # 设置中文字体,Windows 用 SimHei,Mac 用 Arial Unicode MS matplotlib.rcParams["font.sans-serif"] = ["SimHei"] matplotlib.rcParams["axes.unicode_minus"] = False top15 = city_stats.head(15).sort_values("mean_pm25") fig, ax = plt.subplots(figsize=(10, 8)) bars = ax.barh(top15["city"], top15["mean_pm25"], color="#c0392b") # 在每根柱子后面标数值 for bar in bars: width = bar.get_width() ax.text(width + 1, bar.get_y() + bar.get_height()/2, f"{width:.1f}", va="center", fontsize=9) ax.set_xlabel("PM2.5 年均值 (微克/立方米)") ax.set_title("中国主要城市 PM2.5 浓度排名 Top 15") plt.tight_layout() plt.savefig("city_rank.png", dpi=150) plt.show()barh是横向条形图,城市名在 y 轴,不会重叠。ax.text手动标数值比默认的plt.bar_label更可控,位置和格式都能调。dpi=150保证导出图在报告里不糊。如果城市数量必须全画,那就把figsize的高度加大,或者改用 pyecharts 做交互图,鼠标悬停看数值。
4.2 月度趋势折线图:多城市对比怎么不糊成一团
多城市月度趋势如果全画在一张图上,十几条线叠在一起就是一团乱麻。我的做法是选 3 到 5 个代表性城市,用不同颜色和线型区分,其余城市用灰色背景线做参照。
# 选几个代表城市 focus_cities = ["北京", "上海", "广州", "成都", "西安"] monthly_focus = monthly[monthly["city"].isin(focus_cities)] fig, ax = plt.subplots(figsize=(12, 6)) # 先画所有城市的灰色背景线 for city in monthly["city"].unique(): if city not in focus_cities: data = monthly[monthly["city"] == city] ax.plot(data["month"], data["PM2.5"], color="lightgray", linewidth=0.8, alpha=0.5) # 再画重点城市 for city in focus_cities: data = monthly_focus[monthly_focus["city"] == city] ax.plot(data["month"], data["PM2.5"], marker="o", label=city, linewidth=2) ax.set_xticks(range(1, 13)) ax.set_xlabel("月份") ax.set_ylabel("PM2.5 月均值 (微克/立方米)") ax.legend() ax.grid(alpha=0.3) plt.tight_layout() plt.savefig("monthly_trend.png", dpi=150) plt.show()灰色背景线用alpha=0.5降低透明度,既提供整体分布参照,又不抢重点城市的视觉焦点。marker="o"让每个月的点可见,方便读者对应具体数值。set_xticks(range(1,13))保证 12 个月都显示,不会因为某月缺数据就跳过刻度。
4.3 用 pyecharts 做可交互的城市污染地图
如果报告要在网页上展示,pyecharts 的地图组件比静态图更直观。它支持按城市填色,鼠标悬停显示具体数值,适合做可视化大屏或在线报告。
from pyecharts import options as opts from pyecharts.charts import Map # 准备地图数据,格式是 [(城市名, 数值), ...] map_data = [(row["city"], round(row["mean_pm25"], 1)) for _, row in city_stats.iterrows()] map_chart = ( Map() .add("PM2.5年均值", map_data, "china") .set_global_opts( title_opts=opts.TitleOpts(title="中国城市 PM2.5 分布"), visualmap_opts=opts.VisualMapOpts( min_=0, max_=100, is_piecewise=True, pieces=[ {"min": 0, "max": 35, "label": "优", "color": "#2ecc71"}, {"min": 35, "max": 75, "label": "良", "color": "#f1c40f"}, {"min": 75, "max": 115, "label": "轻度污染", "color": "#e67e22"}, {"min": 115, "max": 150, "label": "中度污染", "color": "#e74c3c"}, {"min": 150, "label": "重度污染", "color": "#8e44ad"}, ] ) ) ) map_chart.render("pm25_map.html")Map的城市名要和 pyecharts 内置的中国地图城市名对得上,否则该城市不会显示。is_piecewise=True开启分段显示,比连续渐变更容易读出污染等级。pieces里的分段阈值参考了空气质量等级划分,颜色从绿到紫递进,读者一眼就能定位重污染区域。render输出 HTML 文件,浏览器直接打开就能交互。
5. 避坑与排查:PM2.5 分析里最容易翻车的 5 个地方
这一章记录的是我在做空气质量数据分析时真实踩过的坑,每一个都对应「现象 → 原因 → 解决」的完整链路,新手照着排查能省不少时间。
5.1 城市名不统一导致 groupby 结果分裂
现象:按城市分组后,发现「北京」和「北京市」各出现一次,均值算了两遍。 原因:原始数据里城市名有的带「市」有的不带,还有的用了拼音或简称。 解决:在清洗阶段统一城市名,用df["city"] = df["city"].str.replace("市", "")去掉后缀,再检查一遍df["city"].unique()确认没有重复变体。
5.2 日期解析失败导致时间聚合全空
现象:df["date"].dt.month报错,或者聚合出来只有 NaN。 原因:日期列格式不统一,有的2023-01-01,有的2023/1/1,还有的是 Excel 序列号。 解决:用pd.to_datetime(df["date"], errors="coerce", format="mixed")让 pandas 自动推断格式,解析失败的用df[df["date"].isnull()]单独检查,必要时手动指定format。
5.3 插值把整段缺失补成直线
现象:某个城市连续两个月没数据,插值后画出来是一条平滑直线,看起来数据很完整。 原因:interpolate默认会填补所有缺失,不限制连续长度。 解决:加limit=2或limit_area="inside",只补内部短缺口,两端和长缺口保持 NaN。补完后用df.groupby("city")["PM2.5"].apply(lambda s: s.isnull().sum())检查每个城市还剩多少缺失。
5.4 中文字体没设置导致图上全是方块
现象:matplotlib 图上标题和标签显示成一个个方框。 原因:默认字体不含中文字形。 解决:在画图前设置matplotlib.rcParams["font.sans-serif"] = ["SimHei"](Windows)或["Arial Unicode MS"](Mac),同时设axes.unicode_minus = False避免负号也变方块。如果换了环境字体名不对,用matplotlib.font_manager.findfont查一下系统里有哪些中文字体可用。
5.5 相关系数把非线性关系算成不相关
现象:PM2.5 和 O3 的皮尔逊相关系数接近 0,但图上明明有规律。 原因:皮尔逊只测线性相关,PM2.5 和 O3 在不同季节可能是正相关也可能是负相关,混在一起算就抵消了。 解决:分季节或分城市算相关系数,或者改用斯皮尔曼相关系数df[pollutants].corr(method="spearman"),它对非线性单调关系更敏感。
6. 进阶技巧:把分析脚本变成可复用的报告流水线
前面几章把单次分析的流程走通了,但如果你每周都要更新一份 PM2.5 报告,每次都手动改文件名、调参数、重新跑一遍,迟早会烦。这一章讲怎么把这套脚本整理成一条可复用的流水线,核心思路是:配置和逻辑分离、中间结果落盘、图表批量导出。
先看一个最小可用的配置化改造。把城市列表、时间范围、输出路径这些容易变的东西抽到一个字典里,主流程只读配置不写死参数。
import yaml from pathlib import Path # config.yaml 内容示例: # input_path: data/city_air_quality.csv # output_dir: output/ # focus_cities: [北京, 上海, 广州, 成都, 西安] # pm25_limit: 75 # min_valid_days: 30 def load_config(path="config.yaml"): with open(path, "r", encoding="utf-8") as f: return yaml.safe_load(f) def run_pipeline(config): output_dir = Path(config["output_dir"]) output_dir.mkdir(parents=True, exist_ok=True) df = pd.read_csv(config["input_path"], encoding="utf-8") df = clean_data(df, config["min_valid_days"]) stats = compute_stats(df, config["pm25_limit"]) # 中间结果落盘,方便下次直接读,不用重跑清洗 stats.to_csv(output_dir / "city_stats.csv", index=False) df.to_parquet(output_dir / "cleaned.parquet") # 批量出图 plot_city_rank(stats, output_dir / "rank.png") plot_monthly_trend(df, config["focus_cities"], output_dir / "trend.png") return stats if __name__ == "__main__": cfg = load_config() result = run_pipeline(cfg) print(result.head(10))这个改造的关键点有三个。第一,yaml配置文件让非技术同事也能改城市列表和阈值,不用动代码。第二,to_parquet把清洗后的数据存成列式格式,下次跑分析直接读 parquet,比重新读 CSV 再清洗快好几倍,数据量大时差距更明显。第三,出图函数拆成独立函数,每个函数只负责一张图,方便单独调试和替换。
再进一步,如果你想让报告自动带上生成时间和数据范围,可以在run_pipeline里加一段元信息记录:
from datetime import datetime meta = { "generated_at": datetime.now().isoformat(), "date_range": f"{df['date'].min()} ~ {df['date'].max()}", "city_count": df["city"].nunique(), "valid_records": len(df) } pd.Series(meta).to_json(output_dir / "meta.json", force_ascii=False)这样每次跑完,meta.json里就记录了这次分析的数据快照,报告里引用数据范围时不用再手动查。如果哪天发现某次报告结论异常,翻出对应的 meta 就能确认是不是数据源换了。
最后一个习惯:每次改完清洗逻辑,先拿一个城市的子集跑一遍,确认输出行数和缺失比例合理,再跑全量。我吃过亏,有一次改了异常值阈值没注意,把一批正常的高污染天也过滤掉了,全量跑完才发现排名全变了,只能重来。现在我的做法是固定留一个test_city配置项,改逻辑先跑它,通过了再放开全量。这套流水线不复杂,但能让你从「每次重新做一遍分析」变成「改配置、跑脚本、拿报告」,省下来的时间可以花在解读数据上。希望帮到你。
本文还有配套的精品资源,点击获取