简介:一份面向数据分析初学者及气象数据爱好者的完整项目资料包,基于中国天气网某城市历史天气数据进行全流程分析。项目提供Python爬虫源代码,可自动抓取气温、湿度、风力和空气质量等字段,并支持在Jupyter Notebook中直接运行;后续通过数据清洗与特征统计,生成雷达图、条形图等可视化图表,对城市天气变化规律及指标间关系进行直观解读。压缩包共39个文件,包含20张PNG图表输出,以及15个XML和4个rels文件——它们构成实验报告Word文档的内部结构,整体仅1.69MB,轻便易获取。值得注意的是,资料中附带整理完成的实验报告,详细记录从爬虫采集到图表分析的每一步思路,读者可对照源码复现结果,亦可借此掌握天气数据获取、Pandas处理与Matplotlib绘图的完整链路。当前已有1853人学习下载,尤其适合用于数据分析课程设计、期末实验或入门实战参考。
1. 气象分析到底在分析什么:一份气温数据能挖出什么
拿到十年的逐日气象数据,第一件事不是画图,而是先问一句:这份数据能不能支撑我要下的结论。气象分析(数据分析)这个方向,在从业者手里通常指把观测站或再分析资料变成可量化的气候结论——温度趋势、降水变化、极端事件频率、不同站点之间的差异,而不是做天气预报。两者的差别很实际:预报看未来三天,分析看过去十年到三十年发生了什么、正在怎么变。
它能解决的具体问题,分布在很多行业里。农业上算积温和无霜期,决定播种窗口;能源行业算制冷度日和采暖度日,用来估夏季用电峰值;物流和保险行业则关心极端降水、大风天气的出现频率,用来定风险敞口。适合做这件事的人,一类是数据分析师想往行业方向深挖,另一类是气象相关业务人员想把观测数据转成决策依据。
我一般把这类项目拆成五步走:数据源选型、清洗、时间序列与空间统计、可视化、结论验证。前两步决定后面所有分析的地基,后面每一步都有实实在在的坑。这篇就按这条路径展开,每步都用能直接跑的代码说明白。
2. 先解决数据问题:气象数据有哪些来源,CSV与NetCDF格式怎么选
2.1 三种常见数据源:观测站、再分析资料、气象API
做气象分析,数据源的选择几乎决定了分析的上限。观测站数据来自国家气象信息中心、NOAA GHCN这类机构,特点是站点的实测记录,精度最高,但站点分布不均——东部密、西部稀,而且部分站点序列有断点。再分析资料最常见的是欧洲中期天气预报中心的ERA5和NCEP再分析资料,它们是模式与观测同化出来的全球网格数据,空间上覆盖完整、时间序列长,适合做区域尺度的气候诊断,但单点数值和实测有偏差。第三类是气象API,比如Open-Meteo这类免费接口,适合快速做原型验证,不需要下载文件,但历史深度和字段完整度有限。
| 数据源 | 空间粒度 | 时间覆盖 | 适合场景 | 获取成本 |
|---|---|---|---|---|
| 观测站数据 | 站点点位 | 数十年,逐日/逐时 | 单站或多站对比、趋势分析 | 需注册申请,审核后下载 |
| 再分析资料 | 全球网格,0.25°~1° | 数十年,逐小时/逐日 | 区域空间分析、缺测补全、气候诊断 | 免费,数据量大 |
| 气象API | 站点或网格 | 受接口限制 | 原型验证、快速出图 | 免费额度,有限流 |
选型逻辑很简单:做地方尺度的分析,优先观测站;做空间分布和区域对比,用再分析资料;项目初期摸流程,先用API把代码跑通。常见做法是混用——观测站数据为主,再分析资料做交叉验证,这样结论才经得起追问。
2.2 拿到数据第一步:清洗缺失值与统一时间格式
气象原始数据永远不会是干净的直接可用状态。以中国气象数据网的站点逐日数据为例,文件里常见三类坑:缺失值用特定数值填充(32744、32766这类),气温单位是0.1℃,降水单位是0.1mm。如果不知道这些约定直接读,后面的趋势分析全是错的。
import pandas as pd # 读取站点日值数据,na_values把特定数值识别为缺失 df = pd.read_csv( "station_daily.csv", na_values=[32744, 32766, 9999, -9999], usecols=["station", "date", "tem", "pre", "wind"], ) # 中国站点数据中,气温和降水的原始单位是0.1℃和0.1mm df["tem"] = df["tem"] / 10.0 df["pre"] = df["pre"] / 10.0 # 统一时间列,构建DatetimeIndex df["date"] = pd.to_datetime(df["date"], format="%Y%m%d") df = df.set_index("date").sort_index() print(df.head()) print(df.isna().sum())这段代码做了三件事:缺测识别、单位换算、时间索引构建。na_values参数是关键,它把气象数据里常见的填充缺测值统一映射为NaN,后面所有统计天然忽略缺失。单位换算藏得很深——如果没有看过数据说明文档,做出来的温度曲线会是真实值的10倍,这种错误在数据量大时很难肉眼看出来。to_datetime的时间格式参数%Y%m%d对应当天日期列的原始字符串,如果日期列是20240101这种八位数字,这个格式刚好匹配。
2.3 把NetCDF网格数据变成可分析的表格
再分析资料的NetCDF文件没法直接用Pandas打开,需要一个转换步骤。常见做法是用xarray读取后再选点或区域聚合,转成DataFrame做后续分析。这一步的难点不在API调用,而在理解NetCDF的维度结构——通常三维是(time, lat, lon),但不同数据集的纬度顺序和单位不一样,ERA5的经纬度单位是度,温度单位是开尔文,这些都要先确认。
import xarray as xr import pandas as pd # 打开再分析资料NetCDF文件 ds = xr.open_dataset("era5_single_level.nc") print(ds.coords) # 确认维度名称和范围 # 用最近邻方法提取目标站点所在格点(北京约39.9N, 116.4E) lat_target, lon_target = 39.9, 116.4 # 低温转摄氏,开尔文减273.15 ds["t2m_c"] = ds["t2m"] - 273.15 # 选择时间范围(示例取2020年) df_series = ( ds["t2m_c"] .sel(time=slice("2020-01-01", "2020-12-31"), lat=lat_target, lon=lon_target, method="nearest") .to_dataframe() .drop(columns=["lat", "lon"]) .dropna() ) print(df_series.head())这段代码的核心是sel加method="nearest",它能自动找到离目标经纬度最近的网格点,避免手工找索引。需要留意的是再分析资料的默认时间是UTC,和本地观测站的时间系统差8小时,如果你要对比日值数据,得先做时区平移(第5章有详细说明)。dropna用于把海陆边界上落在海里的网格点剔除,这些位置没有有效观测同化,数值是填充值。
3. 用Pandas做温度时间序列分析:气候态、距平与趋势
3.1 构建时间序列与重采样:日值转月值、年值
温度数据按日存储时,噪声很大,直接看趋势线会被逐日波动淹没。标准做法是先按月和按年重采样,用月均值和年均值来观察气候尺度的变化。Pandas的resample在这里有两个必须注意的细节:新版本(2.2+)推荐用字符串 "ME" 表示按月取月末,旧版的 "M" 已经被标记弃用;按年聚合用 "YE",同样是为了消歧。
# 日值转月均值和年均值 monthly_mean = df["tem"].resample("ME").mean() annual_mean = df["tem"].resample("YE").mean() # 查看重采样前后的数据量变化 print(f"原始日值数量: {df['tem'].shape[0]}") print(f"月均值数量: {monthly_mean.shape[0]}") print(f"年均值数量: {annual_mean.shape[0]}") # 把年均值保存为DataFrame annual_df = annual_mean.to_frame(name="annual_tem") annual_df["year"] = annual_df.index.yearresample("ME")的语义是月末(Month End)聚合,它会把每个月所有日值取平均,同时自动处理各月天数不同的问题——2月只有28天也不会被当成30天算。这个细节比groupby(df.index.month)要可靠得多,后者不会自动对齐日历边界,遇到跨年数据时容易出错。重采样之后的数据量大幅缩减,后续回归和可视化都基于月均或年均,趋势信号才不会被压制。
3.2 气候态均值与距平:为什么拿30年做基线
气候分析里有个基础概念叫气候态均值,简单说就是一段足够长的历史时期的平均状态。气象上通常用30年作为标准基线,WMO推荐的经典时段是1981-2010,现在越来越多人切换到1991-2020。用这个基线算出来的差值叫距平,距平为正代表偏暖,为负代表偏冷。比起绝对温度,距平更适合做趋势分析,因为它剔除了站点海拔和纬度带来的本底差异。
# 以1991-2020为气候态基线,计算逐日气候态均值 clim_start, clim_end = "1991", "2020" clim_mask = (df.index >= clim_start) & (df.index <= clim_end) clim_data = df.loc[clim_mask, "tem"] # 按一年中的第几天分组,计算逐日气候态 daily_clim = clim_data.groupby(clim_data.index.dayofyear).mean() # 计算全序列每日距平 df["anomaly"] = df["tem"] - df.index.dayofyear.map(daily_clim) # 月距平和年距平 monthly_anom = df["anomaly"].resample("ME").mean() annual_anom = df["anomaly"].resample("YE").mean() print(annual_anom.tail())关键点在于用dayofyear分组计算逐日气候态,这样能保留季节内每一天的基准值,而不是每个月用一个粗粒度平均值。如果只用月气候态,1月上旬和下旬的温差会被抹平,距平序列里会混入季节残留信号。基线期的选择直接影响距平符号——同一年的1月,用1981-2010算可能是正距平,切到1991-2020可能就变成负距平,这是正常现象,报告里必须写清楚用哪个时段。
3.3 趋势分析与可视化:斜率怎么算才不被夏季波动干扰
算温度趋势最直接的方法是线性回归,以年份为自变量、年距平为因变量,斜率就是每十年的变化量。但这里有个统计陷阱:如果直接用月距平做回归,相邻月份之间的自相关会让趋势的显著性被高估。常见做法是回归前先聚合成年均值,牺牲一些样本量,换来自变量的独立性。
from scipy.stats import linregress import matplotlib.pyplot as plt # 准备回归数据 analysis_df = annual_anom.dropna().to_frame(name="anom") analysis_df["time_idx"] = range(len(analysis_df)) # 线性回归 slope, intercept, r_value, p_value, std_err = linregress( analysis_df["time_idx"], analysis_df["anom"] ) # 输出每十年变化量 years_per_decade = 10 trend_per_decade = slope * years_per_decade print(f"趋势: {trend_per_decade:.2f} ℃/10年, p值: {p_value:.4f}") # 绘图 plt.figure(figsize=(10, 5)) plt.plot(analysis_df.index, analysis_df["anom"], label="年距平", color="black", lw=0.8) plt.plot(analysis_df.index, intercept + slope * analysis_df["time_idx"], label=f"线性趋势 {trend_per_decade:.2f} ℃/10年", color="red", lw=2) plt.axhline(0, color="gray", ls="--", lw=0.5) plt.legend() plt.xlabel("年份") plt.ylabel("温度距平 (℃)") plt.title("年均温度距平与线性趋势") plt.show()linregress返回的slope是年平均距平随序号变化的速率,乘10就是每十年的变化量。p_value用于判断趋势是否统计显著,小于0.05通常被认为不是随机波动造成的。绘图时把距平序列和回归线叠在一起,能直观看出趋势是否稳定——如果序列前30年平稳、后10年急剧上升,单一线性趋势会把这段转折掩盖掉。遇到这种形态,更好的做法是分时段回归,而不是依赖全序列一个斜率。
4. 降水与极端事件分析:为什么不能照搬温度的套路
4.1 降水的统计特性:零膨胀与偏态分布
降水数据和温度数据在统计性质上完全不是一回事。温度接近正态分布,均值有物理意义;而降水是零膨胀的偏态分布——大部分日子不下雨,数值为0,少数日子出现大值,均值会被几场极端暴雨拉高,不能代表典型状态。如果把降水当温度那样求均值、画趋势,结论会很离谱。
# 查看降水的分布特征 pre = df["pre"].dropna() # 基本统计量 stats = pre.describe() print(stats) # 中位数和众数 median_pre = pre.median() zero_ratio = (pre == 0).mean() print(f"降水日比例(>0.1mm): {(pre > 0.1).mean():.2%}") print(f"中位数: {median_pre:.1f} mm") # 日降水直方图(对数y轴更直观) import matplotlib.pyplot as plt fig, ax = plt.subplots() ax.hist(pre[pre > 0.1], bins=50, log=True, color="steelblue") ax.set_xlabel("日降水量 (mm)") ax.set_ylabel("天数 (对数坐标)") ax.set_title("降水日降水量分布(剔除无雨日)") plt.show()describe给出的均值和中位数如果差距很大,就已经提示数据是偏态的。zero_ratio告诉你无雨日占比,中国北方很多站点超过70%。画直方图时用了log=True,否则大部分柱子都挤在0-5mm区间,根本看不出极端降水尾巴的形状。对这类数据,描述统计应改用降水日数、百分位数、超过阈值的频次,而不是均值。
4.2 降水日与极端降水阈值:0.1mm和95百分位
气象行业对降水日有明确定义:日降水量大于等于0.1mm算一个降水日。极端降水事件则常用百分位阈值来界定——把历史所有降水日的降水量从小到大排序,取第95百分位作为极端阈值,凡是超过这个值的日子就算极端降水事件。这个方法比固定阈值(比如50mm)更合理,因为不同气候区的降水底数差异巨大,西北站点年降水总量可能不如东南一场暴雨,用统一阈值会漏掉区域特征。
# 定义降水日与极端降水阈值 pre_days = pre[pre >= 0.1] # 95百分位阈值(仅基于降水日) threshold_95 = pre_days.quantile(0.95) print(f"极端降水阈值(95百分位): {threshold_95:.1f} mm") # 统计每年极端降水日数 df["is_extreme"] = df["pre"] >= threshold_95 annual_extreme_days = df["is_extreme"].resample("YE").sum() # 年降水总量 annual_pre = df["pre"].resample("YE").sum() print(annual_extreme_days.tail())用quantile(0.95)代替固定阈值,可以让不同站的极端事件定义在统计上可比。需要留意的是,计算百分位时只用降水日样本,而不是全部天数,否则0值会把阈值压得很低,导致「极端事件」里混入普通降雨。输出每年的极端降水日数后,可以看到它的年际波动通常比温度大得多——降水本来就是高变异变量,一年的极端日数从3天跳到10天不罕见。
4.3 连续干期与雨季集中度:用滑动窗口量化干旱与汛期
除了极端降水事件,农业和水利上更关心的是无雨持续时间和降水集中在哪个季节。连续无雨日是抗旱决策的直接指标,而降水集中度决定了水库调度策略。两者都可以用简单统计实现,不需要复杂的干旱指数模型。
# 计算每年最大连续无雨日数 pre_binary = (df["pre"] < 0.1).astype(int) # 无雨日标记为1 max_dry_spell = {} for year, group in pre_binary.groupby(pre_binary.index.year): # 按连续为1的块分组,取最大块长度 spell = group.groupby((group != group.shift()).cumsum()).sum() max_dry_spell[year] = spell.max() if len(spell) > 0 else 0 # 计算雨季(5-9月)降水占全年比例 seasonal = df.groupby(df.index.month)["pre"].sum() rainy_season_ratio = ( seasonal.loc[5:9].sum() / seasonal.sum() ) print(f"汛期(5-9月)降水占比: {rainy_season_ratio:.1%}")连续无雨日的计算用了shift加cumsum的分组技巧——当天的无雨状态与前一日不同时,分组编号加1,从而把连续的无雨日块切分出来。max_dry_spell的结果直接反映季节性干旱风险。雨季集中度则简单得多,按月汇总后算5到9月占比,华北很多站点这个比例超过70%,意味着汛期之外的半年几乎无降水。
5. 气象数据分析避坑:五个高频翻车点与排查方法
5.1 时区陷阱:UTC与北京时间的8小时偏移
现象:把再分析资料的日降水和观测站数据放在同一张图里对比,发现降水事件总是对不上,甚至日期都差了一天。原因:再分析资料(ERA5、NCEP)时间基准是UTC,而国内观测站数据通常是北京时间,两者相差8小时。日值数据如果按自然日聚合,UTC的一天从北京时间早8点开始,夜间到早晨的降水会被划分到错误的日期。解决:在读取网格数据后先把时间索引转成目标时区,再按日重采样。
# 把UTC时间转为北京时间(UTC+8)再按日聚合 df_utc = df_era5.copy() df_utc.index = df_utc.index.tz_localize("UTC").tz_convert("Asia/Shanghai") df_daily_utc = df_utc.resample("D").mean()tz_localize先给无时区的时间戳标记为UTC,tz_convert再转到北京时间。如果原始数据本身有时区信息,直接tz_convert即可。这个转换必须在所有聚合操作之前做,顺序反了会造成无法挽回的偏移。
5.2 缺测值不一定是NaN:32744、9999、-9999
现象:读进来的数据没有NaN,但画趋势图时出现一个离谱的尖峰,比如7月气温突然变成9999℃。原因:气象数据文件早期用特定数值表示缺测,不同机构约定不同,常见的有32744、32766、9999、-9999,这些值没有在CSV里标记为缺失,被当成真实观测读入。解决:读文件前先打开原始数据说明,把缺测值通过na_values传入,或者读进来后用条件筛选统一替换。
# 检查有没有未识别的缺测值 for col in df.columns: for bad_val in [32744, 32766, 9999, -9999]: n_bad = (df[col] == bad_val).sum() if n_bad > 0: print(f"列 {col} 中发现缺测值 {bad_val},数量 {n_bad}") # 统一替换为NaN df = df.replace([32744, 32766, 9999, -9999], np.nan)这段排查代码建议在清洗流程里固定跑一次,特别是拿到新数据源时。气象站数据的说明文档经常藏在下载包的readme里,里面会写明缺测值编码和单位信息,花两分钟读一遍能省一整天排错时间。
5.3 闰年与2月29日:重采样的隐蔽多一天
现象:按年对比各月均值时,每年2月的平均值出现小幅但系统的偏差,某几年数值明显不同。原因:2月有28天和29天两种长度,如果按自然月重采样,日值数量不同导致月均值权重变化;更隐蔽的是dayofyear在闰年3月之后会比平年多一天,导致逐日气候态对齐错位。解决:月重采样用resample("ME")可以避免多数问题;计算逐日气候态时,对闰年的2月29日做单独处理。
# 剔除2月29日,保证逐日气候态对齐 df_clean = df[ ~((df.index.month == 2) & (df.index.day == 29)) ] # 重新按dayofyear算气候态 daily_clim = df_clean.groupby(df_clean.index.dayofyear).mean()去掉2月29日只损失一个样本,对月均值和气候态的影响微乎其微,但能消除每年多一天带来的对齐偏差。如果做的是逐日气候态产品发布,这个步骤特别关键,否则3月1日之后每一天的气候态基准都会错位一天。
5.4 网格数据取点:最近邻不是「取整」
现象:用sel(lat=39.9, lon=116.4)从NetCDF里取北京的气温,和站点实测对比,相关系数很高但绝对偏差一直存在,而且偏差在不同季节大小不一。原因:ERA5的经纬度网格是0.25°间隔,格点坐标可能是39.75、40.0这种值,直接传入的39.9并不落在格点上;如果手写取整逻辑(round到最近的0.25),可能选到距离目标几十公里外的格点,山地和沿海站点偏差尤其明显。解决:用sel(..., method="nearest"),它内部按距离选择最近格点;更严格的场景先算经纬度距离矩阵再选最近邻。
# 正确方式:最近邻选取 ts_point = ds["t2m"].sel( lat=39.9, lon=116.4, method="nearest" ) # 严谨方式:先确认实际选中的格点坐标 print(f"选中格点: lat={ts_point.lat.values:.2f}, lon={ts_point.lon.values:.2f}")打印选中格点的坐标是个好习惯——它告诉你实际用的数据来自哪个网格点,而不是想当然认为就是目标位置。地形复杂区域如果最近格点与实际站点海拔差超过500米,气温偏差会达到3℃以上,这时候应该改用双线性插值,而不是最近邻。
5.5 单位陷阱:气温0.1℃、降水0.1mm
现象:画出的温度曲线在30到40℃之间剧烈震荡,年降水量动辄上万毫米,明显超出常识。原因:国内很多气象数据为节省存储空间,采用缩小10倍的整数编码——气温原始值代表0.1℃,降水代表0.1mm,风速代表0.1m/s。没有按说明除以10,所有分析结果都会系统性放大10倍。解决:在数据加载层统一做单位换算,不要在使用时临时处理。
# 加载时统一单位换算 unit_map = {"tem": 0.1, "pre": 0.1, "wind": 0.1} for col, factor in unit_map.items(): if col in df.columns: df[col] = df[col] * factor这个坑几乎每个用过中国气象站点数据的人都踩过。我现在的习惯是写一个标准加载函数,数据一进内存就完成单位换算和缺失值处理,任何下游分析都不会再碰到原始值。另一个值得做的检查是画图后先看一眼纵轴范围——如果温度范围在-30到45℃之外,第一反应不是分析问题,而是单位或缺测处理出了问题。
6. 进阶:让气象分析结论更稳的三个实操技巧
6.1 滑动平均去噪:窗口宽度怎么定
逐日温度曲线噪声大,逐日降水曲线噪声更大。滑动平均是简单有效的去噪方式,但窗口宽度要根据你想保留的信号尺度来选择:7天窗口能去掉天气尺度波动,保留一周以上的天气过程;30天窗口适合看月际背景;如果想看年际信号,直接用年均值比任何滑动平均都干净。窗口宽度没有统一标准,我在报告里通常同时画原始序列和两种窗口的滑动平均线,让读者自己判断。
df["tem_smooth_7d"] = df["tem"].rolling(window=7, center=True).mean() df["tem_smooth_30d"] = df["tem"].rolling(window=30, center=True).mean()center=True让滑动平均窗口以当前天为中心,而不是只取过去,这样平滑后的序列不会整体滞后。注意到窗口开头和结尾会有NaN,这是边界效应,绘图时可选择从有值的位置开始。
6.2 相关分析的样本量陷阱:气象时间序列的自相关
统计两个气象变量(比如温度和湿度)的Pearson相关系数时,如果直接把逐日数据全丢进去,n值看起来很大(十年有3650个样本),但气象时间序列存在强自相关——今天的温度和昨天几乎一样,有效样本量远小于实际样本量。直接按n=3650查显著性表,会把不相关的变量判成显著相关。修正办法是用有效样本量计算自由度。
def effective_n(series1, series2): """计算考虑自相关后的有效样本量""" n = len(series1) r1 = series1.autocorr(lag=1) r2 = series2.autocorr(lag=1) if abs(r1) >= 1 or abs(r2) >= 1: return n n_eff = n * (1 - r1 * r2) / (1 + r1 * r2) return int(n_eff)使用autocorr(lag=1)得到滞后1阶自相关系数,代入修正公式。常见替代方案是先把数据聚合成月距平再做相关,月距平序列的自相关大幅降低,有效样本量的问题自然缓解。在报告里标注有效样本量是严谨性的加分项,也是很多论文质检点。
6.3 验证结论的两个办法:分段时间对比与交叉数据
任何趋势或相关结论在发布前都应该经过验证。第一个办法是分段稳定性检验:把序列按时间切成前后两半,分别计算趋势,如果两半的趋势符号不同,说明全序列的趋势可能是气候变率噪声而不是稳定趋势。第二个办法是交叉数据验证:用再分析资料算同一个站点的趋势或极端事件频率,与观测站结果对比,符号和量级一致才说明结论不是单一数据源的系统误差造成的。
这两个验证方法不需要额外代码,复用前面章节的回归和统计函数,换数据切片就行。我的习惯是把所有分析封装成以DataFrame为输入的函数,验证时只需要传入不同时段或不同来源的数据。有一次我在一个站点上算出了显著的增温趋势,分段检验发现前半段几乎无趋势、后半段急剧上升,这个信息比单一斜率值重要得多——它说明了变化发生的时段,而不是笼统一句「在变暖」。
气象数据分析做久了会发现,大部分翻车不是统计模型不够高级,而是数据在进入模型之前就错了。我现在的项目里固定保留一个数据校验层,单位、时区、缺测、闰年四道检查全部跑完才开始分析,这套习惯的养成比任何算法都值钱。希望帮到你。
本文还有配套的精品资源,点击获取