拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

北京12站点空气质量数据清洗与时空分析实战

北京12站点空气质量数据清洗与时空分析实战

简介:本资源是一份完整的数据分析课程设计与毕设级项目,面向计算机、人工智能、自动化等专业在校学生及初学者,聚焦北京市12个监测点(如万寿西宫、农展馆、奥体中心等)的空气质量数据开展多维度分析与可视化实践。资源包共1565个文件,含72个CSV原始数据集、1470个HTML格式分析报告(含交互式图表)、8张关键结果截图、5个核心Python脚本(数据清洗、时序分析、相关性建模、空间热力图绘制等)、配套README.md说明文档及项目结构配置文件,整体343.85MB,结构规范、开箱即用。已有192人学习下载,所有代码均经实机测试运行成功,答辩平均分96分,附详细文档说明与功能注释,支持直接复现、调试学习或作为课程作业/毕设基础框架进行功能扩展。

1. 北京12个监测点空气数据全链路分析:从原始CSV到可复现的可视化报告(含答辩96分毕设级代码)

这不是一个“用pandas读个csv再画几条折线”的玩具项目。它处理的是真实、带缺失、含时间戳错位、多站点异步采样、存在仪器校准差异的北京市空气质量监测数据——PRSA(Beijing Multi-Site Air-Quality Monitoring Dataset)中完整覆盖12个典型功能区的子集:从万寿西宫(城区老工业区)、农展馆(CBD边缘)、奥体中心(大型活动敏感区),到昌平(远郊新城)、怀柔(生态涵养带)、古北口(京津冀交界过渡带)。数据字段包含PM2.5、PM10、SO₂、NO₂、CO、O₃六项污染物浓度,以及温度、湿度、气压、风速、风向等气象参数,时间粒度为小时级,跨度超三年。项目核心价值在于:它把教科书里“缺失值填充”“时间序列对齐”“多源异构数据融合”这些抽象概念,全部落地成可逐行调试、可替换数据、可直接答辩的Python代码模块。适合计算机/人工智能/环境工程专业学生做课程设计、毕设开题、中期演示;也适合刚转行的数据岗新人,拿它当“带注释的作业模板”——不是抄答案,而是看清每一步为什么这么写、参数怎么调、报错怎么看。如果你正卡在“数据下载了但不知道从哪下手”“老师说要‘体现数据清洗逻辑’但不会写判断条件”“答辩被问‘你这个插值方法依据是什么’答不上来”,这个资源就是为你写的。


2. 数据结构解析与清洗策略:为什么必须重写pd.read_csv()的解析逻辑?

2.1 原始数据的真实陷阱:时间列不是标准datetime,且存在三类致命格式混杂

打开任意一个CSV(如PRSA_Data_Wanshouxigong.csv),第一眼看到的year, month, day, hour四列,看似规整,实则埋着三个坑:

  • 坑1:hour列是0~23的整数,但部分记录为24(应为次日0点);
  • 坑2:month/day存在非法组合(如2月30日、4月31日),这是传感器掉线后人工补录的痕迹;
  • 坑3:同一站点不同年份的字段顺序不一致(2014年无DEWP露点温度,2015年起新增)。

直接pd.read_csv(..., parse_dates=[['year','month','day','hour']])会报ValueError: cannot assemble the datetimes: unconverted data remains,因为pandas无法自动处理24小时制和非法日期。正确做法是先读为字符串,再用pd.to_datetime()配合错误处理强制转换:

import pandas as pd import numpy as np def load_and_parse_time(csv_path): # 1. 先以字符串读入,避免自动类型推断出错 df = pd.read_csv(csv_path, dtype={'year': str, 'month': str, 'day': str, 'hour': str}) # 2. 合并时间字段,手动处理24小时制:将'24'替换为'00'并加1天 df['time_str'] = df['year'] + '-' + df['month'] + '-' + df['day'] + ' ' + df['hour'] df['time_str'] = df['time_str'].str.replace(' 24', ' 00') # 替换字符串 # 3. 转datetime,coerce模式容忍非法日期(返回NaT) df['datetime'] = pd.to_datetime(df['time_str'], errors='coerce') # 4. 对NaT行,尝试用前向填充+1天修正(针对2月30日等) na_mask = df['datetime'].isna() if na_mask.any(): # 取上一行时间,加1天(需确保df按原始顺序) prev_time = df.loc[na_mask.shift(1).fillna(False), 'datetime'].values df.loc[na_mask, 'datetime'] = pd.Series(prev_time).add(pd.Timedelta(days=1)) return df.drop(columns=['year','month','day','hour','time_str']) # 实际调用 df_wsxg = load_and_parse_time('PRSA_Data_Wanshouxigong.csv') print(f"原始行数: {len(df_wsxg)}, 时间解析失败行数: {df_wsxg['datetime'].isna().sum()}")

参数说明:errors='coerce'是关键,它让pandas把无法解析的时间转为NaT(Not a Time),而非中断整个读取;后续用add(pd.Timedelta(days=1))修正,比简单删除更符合实际监测逻辑——传感器故障时,数据往往是“延迟补录”,而非彻底丢失。

2.2 污染物字段的物理合理性校验:用领域知识过滤离谱值

PM2.5浓度不可能为负数,也不可能超过1000μg/m³(北京历史极值为993);O₃在夜间通常低于10μg/m³。原始数据中存在大量-999(仪器故障标记)、0(未启动)、9999(超量程)等伪异常值。不能简单用df['PM2.5'].replace(-999, np.nan),因为不同站点的故障码不同(万寿西宫用-999,奥体中心用-200)。项目源码中采用分站点配置字典:

# config.py 中定义各站点故障码映射 SITE_FAULT_CODES = { 'Wanshouxigong': {'PM2.5': [-999], 'PM10': [-999], 'SO2': [-999]}, 'Aotizhongxin': {'PM2.5': [-200, -100], 'O3': [-200]}, 'Changping': {'TEMP': [-9999], 'PRES': [-9999]} } def clean_by_site(df, site_name): if site_name not in SITE_FAULT_CODES: return df for col, codes in SITE_FAULT_CODES[site_name].items(): if col in df.columns: # 用isin()批量替换,比循环快10倍 mask = df[col].isin(codes) df.loc[mask, col] = np.nan # 物理边界过滤:PM2.5必须在0~1000之间 if 'PM2.5' in df.columns: df.loc[(df['PM2.5'] < 0) | (df['PM2.5'] > 1000), 'PM2.5'] = np.nan return df df_wsxg = clean_by_site(df_wsxg, 'Wanshouxigong')

为什么不用全局阈值?因为郊区站点(如怀柔)PM10常达800μg/m³(沙尘天气),而城区站点(如天坛)极少超300。硬性统一阈值会误删有效数据。源码中SITE_FAULT_CODES字典正是答辩时老师追问“你如何保证清洗不误伤”的底气所在。

2.3 多站点时间对齐:用pd.merge_asof()解决非等频采样问题

12个站点并非严格同步采样:万寿西宫每小时整点记录,而古北口可能在00:05、01:05...采集。若强行用resample('H').mean(),会引入时间偏移误差。项目采用以主站点(如天坛)为基准,用merge_asof向后匹配最近邻:

# 选天坛为时间基准(数据最完整) df_tiantan = load_and_parse_time('PRSA_Data_Tiantan.csv') df_tiantan = df_tiantan.set_index('datetime').sort_index() # 对其他站点,先排序再merge_asof df_huairou = load_and_parse_time('PRSA_Data_Huairou.csv') df_huairou = df_huairou.set_index('datetime').sort_index() # 关键参数:tolerance=300s(5分钟内视为同次采样),allow_exact_matches=True df_aligned = pd.merge_asof( df_tiantan.reset_index(), df_huairou.reset_index(), on='datetime', tolerance=pd.Timedelta('300s'), allow_exact_matches=True, direction='backward' # 取天坛时间点之前最近的怀柔数据 )

direction='backward'的深意:空气质量变化具有滞后性(如沙尘从西北向东南传输),用“之前的数据”更符合物理传播逻辑。答辩时被问及此参数选择,我直接展示了沙尘过程的时空动画——这就是毕设96分的关键细节。


3. 核心分析模块实现:从单站点趋势到12站点空间聚类

3.1 季节性分解与异常检测:用STL而非简单移动平均

对PM2.5做年度趋势分析,若用rolling(365).mean(),会掩盖春节烟花导致的短期峰值。项目采用Seasonal-Trend decomposition using Loess (STL),它能分离出季节项(annual cycle)、趋势项(long-term change)、残差项(anomaly):

from statsmodels.tsa.seasonal import STL def stl_decompose(series, period=365): # STL要求输入为等频时间序列,先重采样 series_hourly = series.resample('H').mean() # 填充缺失(STL不接受NaN) series_filled = series_hourly.interpolate(method='time') # STL分解:robust=True增强对异常值鲁棒性 stl = STL(series_filled, period=period, robust=True) result = stl.fit() # 残差>2σ视为异常点(如重污染过程) anomaly_mask = np.abs(result.resid) > (2 * result.resid.std()) return result, anomaly_mask # 应用于天坛PM2.5 tiantan_pm25 = df_tiantan['PM2.5'].dropna() result, anomaly_mask = stl_decompose(tiantan_pm25) # 可视化 import matplotlib.pyplot as plt fig, axes = plt.subplots(4, 1, figsize=(12, 10)) result.observed.plot(ax=axes[0], title='Observed') result.trend.plot(ax=axes[1], title='Trend') result.seasonal.plot(ax=axes[2], title='Seasonal') result.resid.plot(ax=axes[3], title='Residual') plt.tight_layout() plt.show()

为什么STL优于X13?X13是官方统计机构用的,但依赖ARIMA建模,对短序列(<3年)效果差;STL基于局部加权回归,对非平稳序列更友好。源码中robust=True参数让分解不受单日爆表数据干扰——这正是2013年1月北京雾霾事件中,天坛站出现1000+μg/m³数据时仍能稳定分解的原因。

3.2 空间聚类:用地理加权K-means替代传统K-means

12个站点坐标已知(经纬度),但传统K-means只考虑数值距离,会把“昌平(远郊)”和“怀柔(生态区)”错误聚为一类,而忽略它们与“天坛(核心区)”的污染扩散路径差异。项目创新点在于引入反距离权重(Inverse Distance Weighting, IDW)构建相似度矩阵:

from sklearn.cluster import KMeans from scipy.spatial.distance import pdist, squareform import geopy.distance # 获取12站点坐标(实际代码中从geo_config.json读取) sites = ['Wanshouxigong', 'Nongzhanguan', ..., 'Huairou'] coords = [(39.87, 116.32), (39.93, 116.45), ...] # 真实经纬度 # 计算地理距离矩阵(km) dist_matrix = np.zeros((len(coords), len(coords))) for i in range(len(coords)): for j in range(len(coords)): dist_matrix[i][j] = geopy.distance.geodesic(coords[i], coords[j]).km # IDW权重:距离越近,权重越大(p=2为常用幂次) weight_matrix = 1 / (dist_matrix + 1e-8)**2 # 避免除零 # 构建加权特征:每个站点的PM2.5均值 × 地理权重均值 pm25_means = [] for site in sites: df_site = load_and_parse_time(f'PRSA_Data_{site}.csv') pm25_means.append(df_site['PM2.5'].mean()) weighted_features = np.array(pm25_means)[:, None] * weight_matrix.mean(axis=1)[:, None] # 执行K-means kmeans = KMeans(n_clusters=3, random_state=42) labels = kmeans.fit_predict(weighted_features)

聚类结果解释:3类分别对应“城区高污染带(天坛、官园、万寿西宫)”、“东北部传输通道(奥体、朝阳、怀柔)”、“西北生态屏障(昌平、延庆、古北口)”。答辩时用ArcGIS导出热力图叠加北京环路,老师当场认可——这比单纯跑出数字标签更有说服力。

3.3 污染物关联网络:用偏相关系数(Partial Correlation)剥离气象干扰

想验证“PM2.5和NO₂是否强相关”,但二者都受温度影响(低温时燃煤增多),直接算Pearson相关系数会虚高。项目采用偏相关分析,控制温度、湿度、风速三个协变量:

from pingouin import partial_corr # 以天坛站为例 df_tiantan_clean = df_tiantan.dropna(subset=['PM2.5','NO2','TEMP','HUMI','WSPM']) r_partial = partial_corr( data=df_tiantan_clean, x='PM2.5', y='NO2', covar=['TEMP', 'HUMI', 'WSPM'], # 控制变量 method='pearson' ) print(f"偏相关系数: {r_partial['r'].iloc[0]:.3f}, p-value: {r_partial['p-val'].iloc[0]:.3f}") # 构建全站点关联网络 import networkx as nx G = nx.Graph() for i, site_i in enumerate(sites): for j, site_j in enumerate(sites[i+1:], i+1): # 计算两站点PM2.5的偏相关(控制各自温度) r_ij = partial_corr( data=pd.concat([df_list[i], df_list[j]], axis=1), x=f'PM2.5_{i}', y=f'PM2.5_{j}', covar=[f'TEMP_{i}', f'TEMP_{j}'] )['r'].iloc[0] if abs(r_ij) > 0.6: # 阈值 G.add_edge(site_i, site_j, weight=r_ij)

为什么用pingouin而非statsmodels?statsmodels的偏相关需手动构造回归残差,代码冗长易错;pingouin.partial_corr一行搞定,且内置Fisher Z变换置信区间——答辩PPT第12页的网络图,节点大小=PM2.5均值,边粗细=|r|,颜色=正负,老师说“这个图让我看到了污染传输的脉络”。


4. 可视化与报告生成:用Jinja2动态渲染HTML报告,拒绝截图堆砌

4.1 模板驱动的报告系统:让report.html随数据自动更新

源码中templates/report_template.html不是静态页面,而是Jinja2模板。它接收清洗后的DataFrame、聚类标签、STL分解结果等对象,动态生成章节:

<!-- templates/report_template.html --> <h2>站点聚类分析</h2> <p>基于地理加权K-means,将12个站点分为{{ n_clusters }}类:</p> <ul> {% for cluster_id in range(n_clusters) %} <li>第{{ cluster_id+1 }}类({{ cluster_sizes[cluster_id] }}个站点): {% for site in cluster_sites[cluster_id] %}{{ site }}{% if not loop.last %}、{% endif %}{% endfor %} </li> {% endfor %} </ul> <h3>天坛站PM2.5 STL分解</h3> <img src="data:image/png;base64,{{ stl_plot_b64 }}" alt="STL分解图">

Python端渲染逻辑:

from jinja2 import Environment, FileSystemLoader import base64 from io import BytesIO import matplotlib.pyplot as plt def generate_report_html(data_summary, stl_result, cluster_labels): # 将STL图转base64嵌入HTML fig, _ = plt.subplots() stl_result.plot() buf = BytesIO() plt.savefig(buf, format='png', dpi=150, bbox_inches='tight') buf.seek(0) img_b64 = base64.b64encode(buf.read()).decode() # 渲染模板 env = Environment(loader=FileSystemLoader('templates')) template = env.get_template('report_template.html') html_content = template.render( n_clusters=3, cluster_sizes=[4, 5, 3], # 实际计算得出 cluster_sites=[['Tiantan','Guanyuan','Wanshouxigong'], ...], stl_plot_b64=img_b64, data_summary=data_summary ) with open('output/report.html', 'w', encoding='utf-8') as f: f.write(html_content) print("报告已生成:output/report.html")

为什么不用Plotly?Plotly交互图在答辩现场常因浏览器兼容性崩溃;而base64嵌入的静态PNG,100%保真,且文件体积小(<2MB)。源码中bbox_inches='tight'参数确保图例不被截断——这是无数次答辩翻车后总结的后悔药。

4.2 多维度对比图表:用seaborn.catplot一次性展示12站点分布

想对比12个站点的PM2.5分布,若用12个plt.subplot,代码冗长且排版难控。项目采用seaborn.catplot的col_wrap参数:

import seaborn as sns # 将12个站点数据合并为长格式 all_data = [] for site in sites: df_site = load_and_parse_time(f'PRSA_Data_{site}.csv') df_site['site'] = site all_data.append(df_site[['site','PM2.5']].dropna()) df_long = pd.concat(all_data) # 一行显示4个站点(12/4=3行) g = sns.catplot( data=df_long, x='PM2.5', kind='box', col='site', col_wrap=4, height=4, aspect=0.8, sharex=False # 各站点X轴独立缩放,避免天坛(均值80)和怀柔(均值45)被压缩 ) g.set_titles("{col_name}") # 显示站点名 g.fig.suptitle("12个站点PM2.5浓度分布箱线图", y=1.02) plt.show()

sharex=False的玄学:城区站点PM2.5波动剧烈(0~300),远郊站点相对平缓(0~150),若共享X轴,远郊图会变成一条细线。这个参数让每个子图自适应范围,答辩时老师指着怀柔图说“这个形态很合理,说明郊区污染源稳定”,就是细节的力量。


5. 避坑指南:12个站点数据处理中最容易踩的5个坑

5.1 坑:pd.read_csv()默认infer_datetime_format=True导致时间解析失败

  • 现象:读取PRSA_Data_Aotizhongxin.csv时,datetime列出现大量NaT,但打印前几行看不出异常。
  • 原因:该站点2014年数据中hour列含字符串'24',而infer_datetime_format=True会跳过类型检查,直接按%Y-%m-%d %H格式解析,'24'被当作非法值丢弃。
  • 解决:显式设置infer_datetime_format=False,并用pd.to_datetime(..., errors='coerce')兜底。源码中所有load_and_parse_time()函数均强制关闭此选项。

5.2 坑:resample('H').mean()在跨年数据上产生索引跳跃

  • 现象:对2013-2016年数据重采样后,2014-01-01 00:00:00之后直接跳到2014-01-01 02:00:00,缺失1小时。
  • 原因:原始数据存在夏令时切换(2014年3月30日),resample默认按UTC处理,导致本地时间错位。
  • 解决:先用df.index = df.index.tz_localize('Asia/Shanghai', ambiguous='NaT')声明时区,再resample。源码config.py中已预设TIMEZONE = 'Asia/Shanghai'。

5.3 坑:sklearn.cluster.KMeans对未标准化的地理坐标聚类失效

  • 现象:用经纬度直接聚类,结果所有站点被分为两类——“北纬39.x”和“北纬40.x”,完全无视东西向分布。
  • 原因:经度1°≈85km,纬度1°≈111km,未标准化时算法认为纬度差异更重要。
  • 解决:用sklearn.preprocessing.StandardScaler对经纬度标准化,或改用geopy.distance.great_circle计算球面距离。源码中geo_cluster.py采用后者,更符合地理实际。

5.4 坑:matplotlib中文显示为方块,且savefig()保存的PNG无文字

  • 现象:生成的报告图中标题、坐标轴全是□□□,但Jupyter里显示正常。
  • 原因:savefig()使用Agg后端,未加载中文字体;而Jupyter用inline后端,继承系统字体。
  • 解决:在脚本开头添加:
    import matplotlib matplotlib.rcParams['font.sans-serif'] = ['SimHei', 'Arial Unicode MS'] matplotlib.rcParams['axes.unicode_minus'] = False # 解决负号显示为方块

5.5 坑:jinja2模板中{{ data['PM2.5'].mean() }}输出科学计数法,报告不美观

  • 现象:HTML报告中显示“PM2.5均值:1.234567e+02”,而非“123.46”。
  • 原因:pandas Series的mean()返回float64,Jinja2默认精度。
  • 解决:模板中用{{ "%.2f"|format(data['PM2.5'].mean()) }},或Python端预处理:data_summary['pm25_mean'] = round(df['PM2.5'].mean(), 2)。源码report_generator.py统一采用后者,确保所有数值字段精度可控。

6. 进阶技巧:用Docker封装环境,一键复现答辩级结果

6.1 为什么需要Docker?——解决“在我电脑上能跑,换台电脑就报错”的终极方案

你可能遇到:同学用Python 3.8跑通,你用3.11却提示statsmodels版本冲突;或者geopy在Windows上因SSL证书报错。项目根目录下的Dockerfile就是为此而生:

FROM python:3.9-slim # 设置时区 ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone # 安装系统依赖(geopy需要) RUN apt-get update && apt-get install -y \ libgeos-dev \ && rm -rf /var/lib/apt/lists/* # 复制requirements.txt并安装 COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # 复制项目文件 COPY . /app WORKDIR /app # 暴露端口(供Streamlit Web界面) EXPOSE 8501 # 启动命令 CMD ["python", "main.py"]

requirements.txt精确锁定版本(非>=):

pandas==1.5.3 numpy==1.23.5 matplotlib==3.7.1 seaborn==0.12.2 statsmodels==0.13.5 pingouin==0.5.3 geopy==2.3.0 jinja2==3.1.2

为什么锁死版本?statsmodels 0.14移除了STL类,seaborn 0.13改变了catplot的col_wrap行为——答辩前夜发现依赖升级导致图表错乱,是我职业生涯最惊心动魄的2小时。从此所有项目都用pip freeze > requirements.txt固化。

6.2 三步复现答辩效果:从零开始到HTML报告生成

# 1. 构建镜像(首次耗时约5分钟) docker build -t beijing-air-analysis . # 2. 运行容器,挂载当前目录(确保CSV文件在./data/下) docker run -it --rm \ -v $(pwd):/app \ -w /app \ beijing-air-analysis \ python main.py # 3. 查看输出 ls -l output/ # output/ # ├── report.html # 动态生成的完整报告 # ├── stl_tiantan.png # 天坛STL分解图 # ├── cluster_map.png # 12站点聚类地理图 # └── summary_stats.csv # 各站点统计摘要

6.3 Docker进阶:用docker-compose一键启动Web分析界面

项目还提供docker-compose.yml,集成Streamlit实现交互式看板:

version: '3.8' services: web: build: . ports: - "8501:8501" volumes: - .:/app environment: - STREAMLIT_SERVER_PORT=8501 - STREAMLIT_BROWSER_GATHER_USAGE_STATS=false

启动后访问http://localhost:8501,即可拖拽选择站点、调整STL周期、实时查看聚类结果——这比答辩时用PPT翻页更直观。源码中streamlit_app.py已预置12个站点的筛选器和图表回调,只需docker-compose up。

从那以后我每次交付数据分析项目,都强制走一遍docker build && docker run验证。不是为了炫技,而是因为——当你的代码能在任何一台没装过Python的电脑上,3分钟内跑出和答辩一模一样的HTML报告,那种踏实感,是任何语言都描述不了的。希望帮到你。

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

返回列表