3. 读取与整理环境监测数据
环境监测数据通常以 CSV 或 Excel 形式交付,字段包括监测时间、站点编号、污染物浓度(如 PM2.5、PM10、SO₂、NO₂、O₃、CO)、温度、湿度等。原始数据几乎不可能直接用于绘图:时间列可能是字符串、缺失值以空单元格或 -999 表示、异常值来自仪器故障或校准漂移、采样间隔也不统一。本节的任务是把“原始表格”整理成一条干净、连续、带时间索引的序列,为后续趋势图提供可靠输入。
下面以一份假设的示例数据说明完整流程。假设 air_quality.csv 包含列 station, timestamp, pm25, pm10, so2, no2, o3, temp, rh,时间范围为 2024-03-01 至 2024-03-31,名义采样间隔为 1 小时,但存在缺测、重复记录和个别极端值。所有数值均为教学示例,不代表真实监测结果。
一、读取数据并统一时间格式
CSV 用 pandas.read_csv 读取,Excel 用 read_excel。关键是显式指定时间列解析,而不是让 pandas 猜测:
import pandas as pd
import numpy as np
df = pd.read_csv(
"air_quality.csv",
parse_dates=["timestamp"],
na_values=["", "NA", "N/A", "-999", "-9999"],
dtype={"station": "category"}
)
df = df.sort_values(["station", "timestamp"]).reset_index(drop=True)
print(df.dtypes)
print(df["timestamp"].dt.tz) # None 表示无时区,需确认是否为本地时间
这里有两个容易出错的点。第一,na_values 必须把仪器常用的缺测标记(如 -999)纳入,否则它们会被当作真实浓度参与均值计算,严重拉低或抬高结果。第二,时区必须明确:环境监测常以本地标准时间记录,若跨时区或涉及夏令时,应统一转换为带时区的 DatetimeIndex,例如 df["timestamp"] = df["timestamp"].dt.tz_localize("Asia/Shanghai"),避免重采样时出现重复或缺失的小时。
二、处理缺失值与异常值
缺失值处理要先判断“缺多少、为什么缺”。若某站点某污染物整月缺失率超过 30%,直接插值会制造虚假趋势,应标记为不可用;若只是零星缺测(如单小时掉线),短间隔线性插值是可接受的。异常值则要区分物理不可能值与统计离群值:负浓度、PM2.5 大于 PM10、湿度超过 100% 属于硬性错误,应置为缺失;而持续数小时的极高值可能是真实污染过程(如沙尘),不能一律删除。
# 硬性规则:物理不可能值置为缺失
df.loc[df["pm25"] < 0, "pm25"] = np.nan
df.loc[df["pm10"] < 0, "pm10"] = np.nan
df.loc[df["pm25"] > df["pm10"], "pm25"] = np.nan
df.loc[(df["rh"] < 0) | (df["rh"] > 100), "rh"] = np.nan
# 统计规则:按站点用中位数绝对偏差识别离群点
def flag_outlier(s, k=6):
med = s.median()
mad = (s - med).abs().median()
return (s - med).abs() > k * 1.4826 * mad
for col in ["pm25", "pm10", "so2", "no2", "o3"]:
mask = df.groupby("station")[col].transform(flag_outlier)
df.loc[mask, col] = np.nan
# 短缺口线性插值,长缺口保留为缺失
df = df.set_index("timestamp")
df["pm25"] = df.groupby("station")["pm25"].transform(
lambda s: s.interpolate(method="time", limit=3)
)
使用 MAD(中位数绝对偏差)而非标准差,是因为标准差本身会被极端值拉大,导致漏检;系数 1.4826 使 MAD 在正态假设下与标准差可比,k=6 是较保守的阈值,适合保留真实污染峰值。插值上限设为 3 小时,超过则保留 NaN,让后续聚合时明确知道该时段数据不足。
三、时间索引转换与重采样聚合
把时间列设为索引后,重采样才有意义。resample 的本质是按时间窗口分组再聚合,窗口边界由 closed 和 label 决定,绘图时标签位置必须与窗口含义一致,否则趋势会整体偏移一个间隔。
# 日均值:窗口左闭右开,标签取窗口起始日
daily = (
df.groupby("station")
.resample("1D", closed="left", label="left")
.agg(pm25=("pm25", "mean"),
pm10=("pm10", "mean"),
valid_hours=("pm25", "count"))
.reset_index()
)
# 有效小时数不足 18 的日均值标记为不可靠
daily["reliable"] = daily["valid_hours"] >= 18
聚合时同时输出 valid_hours 是实践中的关键习惯:日均值由多少个有效小时算出,直接决定该点能否进入趋势图。示例中要求至少 18 个有效小时(即 75% 覆盖率),低于此值的日均值应断线或标注,而不是画成连续曲线。若需要小时对齐的日均序列,可用 resample("1D").mean() 后配合 asfreq 补齐日历日,使缺失日期显式出现为 NaN,避免折线图把两个相隔多日的点直接连成直线。
四、结果检查与常见问题
整理完成后不要立刻画图,先做三项检查。其一,daily["valid_hours"].describe() 看覆盖率分布,若中位数低于 20,说明原始数据质量不足以支撑日均趋势。其二,检查时间索引是否连续:daily.set_index("timestamp").asfreq("1D") 后统计 NaN 天数,缺失日期应显式存在而非被跳过。其三,抽样核对:随机取 3 天,用原始小时数据手算均值,与重采样结果比对,确认窗口边界和标签没有偏移。
常见问题有三类。时间解析失败多因格式混杂(如 2024/3/1 8:00 与 2024-03-01 08:00:00 并存),可用 pd.to_datetime(..., format="mixed") 或先统一分隔符。重采样后首尾日期缺失,通常是 closed/label 设置与预期不符,绘图前用 daily.head() 核对标签日期。异常值被误删导致趋势“过于平滑”,则应回看 MAD 阈值是否过小,或改用分位数规则(如超出 99.5% 分位且持续不足 2 小时才视为异常)。
valid_hours、reliable 等质量字段,这样同一套逻辑可复用于其他站点和月份,趋势图上的断点也有据可查,而不是靠人工记忆判断哪段数据可信。至此,原始表格已转换为一条带时间索引、带质量标记的日均序列。后续绘图只需基于 daily 的 timestamp 与浓度列展开,缺失与不可靠点通过 reliable 字段控制断线或标注,趋势解读才有可靠的数据基础。