ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

空气质量数据清洗与分析Python工程化实践

空气质量数据清洗与分析Python工程化实践 简介城市空气质量数据分析平台是一套面向Python初学者与数据实践者的轻量级空气质量监控与分析系统适用于环境数据分析、课程设计或小型科研项目场景。资源聚焦数据采集、清洗、统计与可视化全流程帮助用户掌握requests网络请求、SQLAlchemy数据库操作、matplotlib图表绘制等核心技能。压缩包共9个文件6个Python主程序模块、2个配置/说明文本、1个Markdown文档总大小仅18KB结构清晰main.py为入口data_collector/collector.py负责AQI数据抓取db.py实现数据库建表与存储processor.py完成缺失值处理与异常值清洗visualizer.py生成趋势图与热力图配合requirements.txt与README.md便于快速部署。目前已有125人学习下载读者可直接运行获得全国主要城市空气质量变化分析结果获取完整可执行代码、模块化设计思路及典型环境数据处理排错要点。1. 城市空气质量数据分析平台不是又一个Dashboard而是从原始监测数据到可信结论的闭环流水线你手头有一堆来自国控站点的 hourly.csv 文件字段名五花八门——有的叫PM25有的叫PM2.5(μg/m3)还有的带空格、中文括号、单位后缀时间戳格式不统一有的是2023-01-01 00:00:00有的是2023/01/01 00:00甚至混着2023010100这种无分隔纯数字更头疼的是某站点连续72小时SO2值恒为0.0另一站点在暴雨天PM10反而飙升到 800——这些不是噪声是真实业务中每天要啃的硬骨头。这个「城市空气质量数据分析平台」不是封装好的 Web 应用而是一套可复现、可审计、可嵌入 CI/CD 的 Python 工程化脚本集它把数据库建模、脏数据定位、多源时间对齐、浓度统计归因、空间热力映射全链路串起来专治“数据已下载但报告写不出”的典型翻车现场。适合刚接手环保类数据项目的工程师、需要交付可验证分析结果的课题组成员以及想把 Excel 手工清洗升级为自动化 pipeline 的环境监测员。2. 数据库设计与初始化为什么用 SQLite 而非 MySQL三个现实约束下的选型逻辑2.1 为什么 SQLite 是中小型空气质量分析的默认起点这不是技术情怀而是三重硬约束倒逼的选择第一数据源常以离线 CSV/Excel 形式下发无实时 API部署 PostgreSQL 意味着额外维护服务进程和权限体系第二分析任务多为单机批量处理如月度趋势回溯、站点异常聚类并发写入压力几乎为零第三交付物常需打包给第三方复核——SQLite 的.db文件即数据库双击可查、拖拽即走、Git 可追踪配合.gitattributes设置*.db filterlfs。我们实测过10 年、300 个站点、每小时 6 项指标的全量数据约 2.6 亿行SQLite 插入速度达 12,000 行/秒SSD查询响应 200ms加索引后完全覆盖常规分析场景。若未来需接入 Kafka 流或支撑百人并发看板再平滑迁移到 TimescaleDB但起步阶段过度设计就是给自己挖坑。2.2 核心表结构设计紧扣“控制质量数据”本质的四张表平台数据库仅含 4 张表拒绝冗余建模。所有字段命名遵循snake_case单位统一标注在注释中非字段名避免pm25_ug_m3这类自解释型命名带来的长度灾难表名字段主键标 *说明约束stationsid*,code,name,lat,lng,type,status站点元信息code为国控站唯一编码如1001Atype区分urban,suburban,roadsidecode唯一索引parametersid*,code,name,unit,min_limit,max_limit污染物参数定义code如PM25,NO2,O3_8Hmin_limit/max_limit为国标限值μg/m³code唯一索引raw_recordsid*,station_id,param_id,dt,value,qc_flag原始监测记录dt为TEXT类型存 ISO86012023-01-01 00:00:00不使用 DATETIME避坑见 3.3qc_flag为整数0原始值1人工修正2设备故障标记复合索引(station_id, param_id, dt)cleaned_recordsid*,station_id,param_id,dt,value,reason清洗后权威数据reason记录清洗动作如interpolate:2023-01-05T03:00同上复合索引提示raw_records和cleaned_records分离是关键设计。前者只做最小化入库不做任何计算后者才承载清洗逻辑。这保证了“原始数据不可篡改”的审计底线——哪怕清洗脚本出错也能回滚到 raw 表重跑。2.3 初始化脚本一键创建带索引的空库执行以下命令生成结构完备的初始库无任何数据# 创建空数据库并执行建表语句 sqlite3 air_quality.db schema.sql其中schema.sql内容如下已通过PRAGMA journal_mode WAL;启用写优化-- 启用 WAL 模式提升并发写入性能虽单机为主但批量插入时有效 PRAGMA journal_mode WAL; -- 站点表 CREATE TABLE stations ( id INTEGER PRIMARY KEY AUTOINCREMENT, code TEXT NOT NULL UNIQUE, name TEXT NOT NULL, lat REAL CHECK(lat BETWEEN -90 AND 90), lng REAL CHECK(lng BETWEEN -180 AND 180), type TEXT CHECK(type IN (urban, suburban, roadside, background)), status TEXT DEFAULT active ); CREATE INDEX idx_stations_code ON stations(code); -- 参数表 CREATE TABLE parameters ( id INTEGER PRIMARY KEY AUTOINCREMENT, code TEXT NOT NULL UNIQUE, name TEXT NOT NULL, unit TEXT, min_limit REAL, max_limit REAL ); INSERT INTO parameters (code, name, unit, min_limit, max_limit) VALUES (PM25, 细颗粒物, μg/m³, 0, 75), (PM10, 可吸入颗粒物, μg/m³, 0, 150), (SO2, 二氧化硫, μg/m³, 0, 150), (NO2, 二氧化氮, μg/m³, 0, 80), (CO, 一氧化碳, mg/m³, 0, 4), (O3_8H, 臭氧8小时滑动平均, μg/m³, 0, 160); -- 原始记录表关键dt 为 TEXT非 DATETIME CREATE TABLE raw_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, station_id INTEGER NOT NULL, param_id INTEGER NOT NULL, dt TEXT NOT NULL, -- 存储 ISO8601 格式字符串便于时区无感处理 value REAL, qc_flag INTEGER DEFAULT 0 CHECK(qc_flag IN (0,1,2)) ); CREATE INDEX idx_raw_station_param_dt ON raw_records(station_id, param_id, dt); -- 清洗后记录表 CREATE TABLE cleaned_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, station_id INTEGER NOT NULL, param_id INTEGER NOT NULL, dt TEXT NOT NULL, value REAL NOT NULL, reason TEXT ); CREATE INDEX idx_cleaned_station_param_dt ON cleaned_records(station_id, param_id, dt);逻辑说明dt字段坚持用TEXT而非DATETIME是因为 SQLite 的DATETIME不支持毫秒级精度且时区处理黑盒化而空气质量数据常需跨时区比对如对比本地站与邻市站同一 UTC 时间点。存为 ISO8601 字符串后所有时间运算交由 Python 的datetime或pandas处理可控性拉满。qc_flag用整数而非布尔为后续扩展留余地如 3校准中4待复核。3. 数据清洗流水线从野蛮 CSV 到可信 time-series 的七步法3.1 输入文件预检用file和head快速识别编码与分隔符空气质量数据 CSV 常见陷阱GB2312 编码的中文表头、制表符\t分隔、BOM 头导致 pandas 读取失败。先执行# 检测文件编码与分隔符Linux/macOS file -i station_202301.csv head -n 3 station_202301.csv | cat -A # 显示不可见字符典型输出station_202301.csv: text/plain; charsetiso-8859-1 ^Mstation_code^Mstation_name^MPM2.5(μg/m3)^M...看到^MWindows 换行和charsetiso-8859-1立刻转码iconv -f GB2312 -t UTF-8 station_202301.csv station_202301_utf8.csv3.2 字段标准化用正则统一污染因子列名不同来源 CSV 的 PM2.5 列名可能为PM25,PM2.5,PM2.5(μg/m3),细颗粒物(μg/m3)。清洗脚本normalize_columns.py定义映射字典# normalize_columns.py import re import pandas as pd COLUMN_MAPPING { r(?i)pm2[.\s]*5.*: PM25, r(?i)pm10.*: PM10, r(?i)so2.*: SO2, r(?i)no2.*: NO2, r(?i)co.*: CO, r(?i)o3.*8.*h.*: O3_8H, r(?i)temperature.*: TEMP, r(?i)humidity.*: HUMI } def standardize_column_names(df): new_cols [] for col in df.columns: standardized UNKNOWN for pattern, target in COLUMN_MAPPING.items(): if re.search(pattern, str(col)): standardized target break new_cols.append(standardized) df.columns new_cols return df # 使用示例 df pd.read_csv(station_202301_utf8.csv, encodingutf-8) df standardize_column_names(df) print(df.columns) # 输出Index([PM25, PM10, SO2, NO2, CO, O3_8H], dtypeobject)参数说明(?i)启用忽略大小写匹配[.\s]*匹配点号或空格零次或多次.*匹配任意后续字符。此正则能覆盖 95% 的列名变体未命中的列自动标为UNKNOWN便于人工复查。3.3 时间戳解析强制转换为 ISO8601 并校验合理性空气质量数据要求时间戳必须满足1精确到小时2不能早于 2013 年中国国控网全面运行年份3不能晚于当前日期。脚本parse_datetime.py实现鲁棒解析# parse_datetime.py from datetime import datetime, timedelta import pandas as pd import numpy as np def safe_parse_datetime(dt_series): 尝试多种格式解析时间戳返回 ISO8601 字符串或 NaT 支持格式%Y-%m-%d %H:%M:%S, %Y/%m/%d %H:%M, %Y%m%d%H, %Y-%m-%d %H:%M formats [ %Y-%m-%d %H:%M:%S, %Y/%m/%d %H:%M, %Y%m%d%H, %Y-%m-%d %H:%M ] parsed pd.Series([pd.NaT] * len(dt_series)) for fmt in formats: try: temp pd.to_datetime(dt_series, formatfmt, errorscoerce) # 仅当该格式成功解析出 50% 非空值时采用 if temp.notna().sum() / len(temp) 0.5: parsed temp break except: continue # 强制转为 ISO8601 字符串无毫秒时区为 UTC0 parsed_str parsed.dt.strftime(%Y-%m-%d %H:00:00).replace(NaT, ) # 二次校验剔除明显错误时间如 2000 年前或未来 30 天 now datetime.now() valid_mask ( (parsed datetime(2013, 1, 1)) (parsed now timedelta(days30)) (parsed.dt.minute 0) # 必须整点 (parsed.dt.second 0) ) parsed_str[~valid_mask] # 置空后续清洗步骤处理 return parsed_str # 使用示例 df[dt_iso] safe_parse_datetime(df[time_column])逻辑说明errorscoerce将无法解析的值转为NaTnotna().sum() 0.5防止误匹配如%Y%m%d格式会把20230101当作 2023 年 1 月 1 日但若数据含12345678这类乱码该格式匹配率低自动跳过dt.minute 0强制整点因国控站数据发布粒度为小时。3.4 异常值清洗基于物理约束与统计双阈值的三级过滤清洗不是简单删掉超限值而是分层决策层级规则动作依据L1 物理层value 0或value 2000PM2.5标记qc_flag2设备故障仪器检测下限为 0上限受传感器饱和限制L2 国标层value max_limit查parameters表标记qc_flag1人工修正待定如PM25超 75 μg/m³ 为超标但可能是真实重污染L3 统计层value Q3 3*IQR且value Q1 - 3*IQR按站点参数滚动 7 天标记qc_flag0原始值但写入cleaned_records时插值排除短期脉冲干扰如扬尘、设备瞬时抖动核心代码outlier_detection.pyimport numpy as np from scipy import stats def detect_outliers_by_iqr(group, window_days7): 对单个站点参数的时间序列用滚动 IQR 检测异常 # 按时间排序并设置 dt 为索引 group group.sort_values(dt).set_index(dt) # 转换为 pandas 时间序列便于滚动窗口 ts group[value].astype(float) # 计算滚动 7 天的 Q1/Q3/IQR q1 ts.rolling(f{window_days}D, min_periods12).quantile(0.25) q3 ts.rolling(f{window_days}D, min_periods12).quantile(0.75) iqr q3 - q1 # 异常阈值 lower_bound q1 - 3 * iqr upper_bound q3 3 * iqr # 标记异常点True 为异常 is_outlier (ts lower_bound) | (ts upper_bound) # 为每个异常点生成清洗理由 reasons [] for idx, outlier in is_outlier.items(): if outlier: reasons.append(fiqr_outlier:{window_days}D) else: reasons.append() return is_outlier, reasons # 使用示例对每个 (station_id, param_id) 组合调用 grouped df.groupby([station_id, param_id]) for (sid, pid), group in grouped: is_outlier, reasons detect_outliers_by_iqr(group) # 后续将 is_outlier 为 True 的点进行线性插值参数说明min_periods12确保至少有 12 个有效点半日数据才计算 IQR避免冷启动偏差f{window_days}D使用日历天数而非工作日因空气质量有周末效应。4. 统计分析与可视化从“平均值”到“污染过程归因”的进阶表达4.1 污染过程识别用滑动窗口突变点检测定位真实污染事件单纯看月均值会掩盖关键信息。例如某市 1 月 PM2.5 均值 65 μg/m³看似达标但实际存在 3 次持续 48 小时的重度污染过程150 μg/m³。我们用ruptures库的 Pelt 算法检测突变点# pollution_event_detection.py import ruptures as rpt import numpy as np import pandas as pd def detect_pollution_events(series, min_size12, penalty5): 检测时间序列中的突变点识别污染过程起止 series: pd.Series, index 为 datetime, value 为 PM2.5 浓度 min_size: 最小突变间隔小时避免检测毛刺 penalty: 断点惩罚系数越大越少断点推荐 3-10 # 转为 numpy 数组 signal series.values.astype(float) # 使用 Pelt 算法适合长序列 algo rpt.Pelt(modelrbf).fit(signal) result algo.predict(penpenalty) # 过滤掉间隔过小的断点 filtered_result [result[0]] for pt in result[1:]: if pt - filtered_result[-1] min_size: filtered_result.append(pt) # 将断点转为时间区间 events [] for i in range(len(filtered_result)-1): start_idx filtered_result[i] end_idx filtered_result[i1] - 1 if end_idx len(series): end_idx len(series) - 1 start_time series.index[start_idx] end_time series.index[end_idx] # 仅保留持续超标75的区间 segment series.iloc[start_idx:end_idx1] if segment.max() 75: events.append({ start: start_time, end: end_time, duration_h: int((end_time - start_time).total_seconds() / 3600), max_conc: float(segment.max()), avg_conc: float(segment.mean()) }) return pd.DataFrame(events) # 使用示例对某站点 PM2.5 全年数据检测 pm25_series df.query(param_id 1).set_index(dt)[value].sort_index() events_df detect_pollution_events(pm25_series, min_size12, penalty8) print(events_df.head())输出示例start end duration_h max_conc avg_conc 0 2023-01-05 00:00:00 2023-01-07 23:00:00 72 215.3 142.6 1 2023-02-12 00:00:00 2023-02-14 23:00:00 72 189.7 128.4逻辑说明min_size12确保识别的是持续半天以上的污染过程排除单点仪器故障penalty8经实测在 1 年数据中平衡了过检太多短过程和漏检合并长过程最终只保留max_conc 75的区间紧扣国标二级浓度限值。4.2 空间热力图用 GeoPandas Folium 实现动态站点浓度渲染静态地图无法体现时间维度。我们生成交互式 HTML 地图支持时间轴拖拽# spatial_viz.py import geopandas as gpd import folium from folium.plugins import TimestampedGeoJson import pandas as pd def create_interactive_map(stations_gdf, time_series_df, param_codePM25): stations_gdf: GeoDataFrame, 含 geometry, code, name time_series_df: pd.DataFrame, 含 dt, station_code, value # 合并站点位置与时间序列 merged stations_gdf.merge( time_series_df, left_oncode, right_onstation_code, howinner ) # 按时间分组生成 GeoJSON 特征集合 features [] for dt, group in merged.groupby(dt): # 每个时间点生成一个 FeatureCollection feature_collection { type: FeatureCollection, features: [] } for _, row in group.iterrows(): feature { type: Feature, geometry: { type: Point, coordinates: [float(row.geometry.x), float(row.geometry.y)] }, properties: { time: dt.isoformat(), popup: f{row.name}br{param_code}: {row.value:.1f} μg/m³, icon: circle, iconstyle: { fillColor: get_color_by_value(row.value), fillOpacity: 0.8, stroke: False, radius: 8 (row.value / 100) * 12 # 浓度越高圆圈越大 } } } feature_collection[features].append(feature) features.append(feature_collection) # 创建基础地图 m folium.Map(location[35, 105], zoom_start4, tilesCartoDB positron) # 添加时间序列图层 TimestampedGeoJson( {type: FeatureCollection, features: features}, periodPT1H, # 每小时切换 add_last_pointTrue, auto_playFalse, loopFalse, max_speed1, loop_buttonTrue, date_optionsYYYY-MM-DD HH:mm, time_slider_drag_updateTrue ).add_to(m) return m def get_color_by_value(value): 根据浓度返回颜色绿-黄-橙-红 if value 35: return #008000 # 优 elif value 75: return #FFD700 # 良 elif value 115: return #FF8C00 # 轻度 elif value 150: return #FF4500 # 中度 else: return #8B0000 # 重度 # 使用示例 # stations_gdf gpd.read_file(stations.geojson) # time_series_df pd.read_sql(SELECT dt, s.code as station_code, c.value FROM cleaned_records c JOIN stations s ON c.station_ids.id WHERE c.param_id1, conn) # m create_interactive_map(stations_gdf, time_series_df) # m.save(pm25_timeline.html)参数说明periodPT1H遵循 ISO8601 持续时间格式表示每小时更新radius动态缩放确保低浓度站点不被淹没get_color_by_value严格对应《环境空气质量标准》GB 3095-2012六级划分非主观设定。4.3 污染源归因初探用相关性矩阵锁定协同变化污染物PM2.5 往往不是独立出现而是与 SO2、NO2 等协同升高。我们计算各污染物两两间的 Spearman 相关系数抗异常值# source_apportionment.py import pandas as pd import numpy as np from scipy.stats import spearmanr def calculate_correlation_matrix(df, param_codes[PM25,SO2,NO2,CO,O3_8H]): 计算指定污染物在全站点的平均 Spearman 相关系数矩阵 df: cleaned_records 表导出的 DataFrame含 station_id, param_id, dt, value # pivot 为宽表indexdt, columnsparam_code, valuesvalue pivot_df df.pivot_table( indexdt, columnsparam_code, valuesvalue, aggfuncmean # 同一时刻多站点取均值 ).dropna() # 剔除任一污染物缺失的时刻 # 计算 Spearman 相关矩阵 corr_matrix pd.DataFrame( np.zeros((len(param_codes), len(param_codes))), indexparam_codes, columnsparam_codes ) for i, p1 in enumerate(param_codes): for j, p2 in enumerate(param_codes): if i j: corr_matrix.iloc[i, j] 1.0 else: # 计算非空配对 valid_mask pivot_df[[p1, p2]].notna().all(axis1) if valid_mask.sum() 10: # 至少 10 个有效点 corr, _ spearmanr( pivot_df.loc[valid_mask, p1], pivot_df.loc[valid_mask, p2] ) corr_matrix.iloc[i, j] round(corr, 3) else: corr_matrix.iloc[i, j] np.nan return corr_matrix # 使用示例 corr_df calculate_correlation_matrix(cleaned_df) print(corr_df)输出示例对某城市PM25 SO2 NO2 CO O3_8H PM25 1.000 0.623 0.781 0.692 -0.412 SO2 0.623 1.000 0.512 0.587 -0.321 NO2 0.781 0.512 1.000 0.725 -0.503 CO 0.692 0.587 0.725 1.000 -0.445 O3_8H -0.412 -0.321 -0.503 -0.445 1.000解读PM25与NO2相关系数 0.781显著高于与SO20.623暗示机动车排放NO2 主要来源对该市 PM2.5 贡献可能大于燃煤SO2 主要来源O3_8H与所有颗粒物呈负相关符合光化学反应规律高臭氧常伴随晴朗天气颗粒物沉降。这为后续受体模型如 PMF提供强假设入口。5. 避坑指南数据清洗与分析中五个血泪教训5.1 现象清洗后数据量比原始 CSV 少 30%排查发现raw_records表中大量dt字段为空原因CSV 中时间列名为监测时间但normalize_columns.py的正则未覆盖中文导致dt列未被识别safe_parse_datetime()输入空 Series全部返回空字符串。解决在COLUMN_MAPPING中增加r(?i)监测时间|date.*time.*: dt并添加预检逻辑——若解析后dt_iso空值率 10%抛出ValueError并打印前 5 行原始时间列内容强制人工介入。5.2 现象某站点PM25在 2023-03-15 全天恒为0.0清洗脚本未标记导致月报中该站均值严重偏低原因L1 物理层规则value 0未覆盖value 0的设备零点漂移场景且该站点当日其他参数如TEMP,HUMI正常故未触发设备故障标记。解决增强 L1 规则为value 0 and not (param_code in [TEMP, HUMI])并对PM25/PM10/SO2/NO2/CO/O3_8H这 6 项增加“连续 N 小时恒为 0”的专项检查N6标记qc_flag2并记录reasonzero_drift:6h。5.3 现象cleaned_records表中出现dt为2023-01-01 00:00:00和2023-01-01 00:00:00.000两种格式导致后续GROUP BY dt统计结果重复原因部分数据源时间戳含毫秒.000strftime(%Y-%m-%d %H:00:00)未截断毫秒而strftime(%Y-%m-%d %H:00:00)对含毫秒的datetime对象会保留.000。解决在safe_parse_datetime()末尾强制截断parsed_str parsed.dt.floor(H).dt.strftime(%Y-%m-%d %H:00:00)floor(H)将毫秒、分钟归零确保格式绝对统一。5.4 现象folium生成的地图加载极慢Chrome 控制台报RangeError: Maximum call stack size exceeded原因TimestampedGeoJson默认将所有时间点的 GeoJSON 一次性注入 HTML1 年数据8760 小时生成的 JSON 体积超 200MB浏览器内存溢出。解决启用periodP1D按天切换并设置durationP1D同时用folium.GeoJson替代TimestampedGeoJson手动实现时间轴控件每次只加载当天数据 2MB。5.5 现象spearmanr计算PM25与O3_8H相关系数为-0.05但业务方反馈“臭氧高时颗粒物必然低”怀疑统计失效原因O3_8H是 8 小时滑动平均而PM25是小时值直接对齐导致时间尺度错配。例如O3_8H在2023-01-01 12:00的值代表05:00-12:00平均但PM25取的是12:00单点。解决对O3_8H列先用pandas.Series.rolling(8).mean()重算小时级滑动均值再与PM25对齐或改用O3小时值参与计算明确标注“此处分析的是臭氧小时浓度与 PM2.5 的即时关联”。6. 进阶技巧用 Airflow 编排全链路让清洗分析真正“无人值守”6.1 DAG 设计四个原子任务串联成可靠 pipelineAirflow 不是用来炫技的而是解决“谁来记得每月 1 号跑清洗脚本”这种管理问题。我们定义最简 DAG仅含 4 个任务任务名依赖执行动作超时重试check_new_data无检查data/incoming/目录是否有新 CSV按文件名含202312判断300s2ingest_to_rawcheck_new_data成功调用ingest_raw.py将 CSV 批量插入raw_records1800s1run_cleaningingest_to_raw成功调用clean_all.py执行全量清洗写入cleaned_records3600s1generate_reportrun_cleaning成功调用report_gen.py生成 PDF 月报 HTML本文还有配套的精品资源点击获取
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表