尧图精选

Python实战:北京近10年历史天气数据分析与可视化

🕒 发布时间:2026/9/7 16:04:14 📁 来源:尧图网络
最近想搞明白一个特别具体的问题北京这些年是不是真的“一年比一年热”。光靠体感说话实在不靠谱夏天热几天就嚷嚷极端冬天冷几天又说寒冬回来了印象流永远扯不清。索性花了一下午把北京最近10年的历史天气数据全部拉下来从数据接口到可视化画完整条“全年天气变化曲线”。这篇文章就是这次实操的完整复盘从接口选型、请求参数设计、数据清洗到Matplotlib出图一路踩坑一路解决最后甚至能算出每个气象站意义上的年均升温速率。无论你是想拿Python做数据分析练手还是想验证“暖冬”“异常高温”这些说法这套流程都值得直接抄走。1. 项目到底在解决什么问题1.1 一句话说清楚这个项目这个标题的核心任务不是去写个爬虫抓一次两次天气而是拿到一个连续10年、以“天”为粒度的北京全年气象记录再把它变成一条能直接看的曲线最好是既有每年内部的季节波动又有跨年趋势判断。说白了就是要解决三个问题数据从哪来才合规、稳定、又不需要折腾一堆账号权限。怎么按“年”把365天左右的逐日数据连续取出来中间还不能断。拿到之后用什么方式可视化能同时看出“周期规律”和“长期趋势”。我一开始也纠结过要不要自己从网页抓取后来仔细想想决定放弃。网页抓取面临页面结构改版、字体反爬、访问频率限制等问题花不少时间处理结果还不一定稳定。真正做数据分析的时候我们优先找结构化、有明确使用边界的数据接口把精力放在清洗和解读上而不是跟反爬斗智斗勇。1.2 适合谁来参考如果你符合下面任一条这篇文章应该能帮你省不少时间用Python做数据分析和可视化但还没试过真实历史气象数据想拿一份有年份跨度、有趋势变化的数据练手的人。想观察城市气候变化但不清楚历史上哪些气象数据是能免费公开获取的人。之前只会用现成Excel表做图表想换成脚本自动更新、数据能复现的人。做环境、能源、农业相关项目需要把气温、降水等数据作为背景特征嵌入模型的人。这个项目的产出是两张图和一个数据表第一张图把10年的逐日气温叠在一起能看出每年夏季高温的峰值位置第二张图是对年均气温做趋势线拟合判断这10年到底暖没暖、暖了多少。看起来简单但真正实现下来里面涉及的细节非常多。2. 方案选型为什么用历史天气服务接口而不是爬虫2.1 常见方案对比做历史天气数据获取业内常见的路径主要有三条爬虫、商业API、开放气象服务接口。我直接做了个对比表方便你一眼看懂取舍。方案优点缺点我的结论爬取天气网站历史页面数据看起来“够细”通常是中文页面栏位直观页面结构不稳定部分站点有拦截策略需要处理编码、动态加载长期维护成本高不推荐除非目标数据极其特殊国内气象部门公开数据平台数据权威站网覆盖完整部分内容需要实名注册、审核接口文档和数据格式对新手不友好适合科研用途不适合快速Demo国际开放气象服务接口开放、免费额度充足返回JSON/CSV字段标准历史数据可直接回溯数十年需要理解时区、经纬度、变量编码偶尔有服务限流推荐作为个人项目和教学项目首选对于个人做趋势分析真正重要的是“数据能连续、口径一致、字段定义明确”。我最后选了开放气象历史数据接口它不需要申请API Key只需要用北京站点的经纬度坐标指定起止日期就能返回每日最高温、最低温、平均温、降水量这些核心字段。而且它的历史数据能做到按天回溯覆盖十年完全没有问题。2.2 数据字段与坐标确认历史天气数据接口的请求核心是经纬度和所需变量。不同坐标系出来的结果会差很多所以一般用GCJ02或WGS84等常见坐标体系。北京中心区常用的近似坐标是北纬39.9042度、东经116.4074度。我直接用这个坐标作为代表点做城市级趋势分析是足够准确的。接口支持的每日变量很多我这次选了以下字段temperature_2m_max距地面2米处的日最高气温单位默认是摄氏度。temperature_2m_min距地面2米处的日最低气温。temperature_2m_mean日平均气温通常由多个时次平均得到。precipitation_sum日累计降水量单位毫米。windspeed_10m_mean距地面10米高度的日平均风速单位千米每小时。snowfall_sum日累计降雪量单位厘米这个我后来没重点用但拉数据时顺手存了。这些参数的命名比较反直觉。比如“2m温度”不是指地下2米而是气象观测规范中百叶箱距地面约1.5到2米处的温度这是全球通用的观测标准。刚开始跑数据时经常会忽略后缀导致取错变量名接口直接报错或者返回一堆空值。如果你已经有长期观测或者研究需要也可以增加相对湿度、地表温度、气压等变量但折线图展示还是以气温为主。变量选得越多响应体越大对于这种十年长度的请求来说没有必要。3. 核心代码实现请求、解析、清洗一条龙3.1 先定一个稳健的请求策略写代码前我先想清楚了请求策略。近10年一次性请求也可以但我更倾向于按年循环请求。这么做的好处有三个一是单次响应体小解析快二是如果某一年请求失败重试成本低不用把整个十年全重拉一遍三是年份边界非常清晰后面做“按年统计”时不容易串位。import time import requests import pandas as pd API_URL https://archive-api.open-meteo.com/v1/archive LATITUDE 39.9042 LONGITUDE 116.4074 TIMEZONE Asia/Shanghai START_YEAR 2014 END_YEAR 2023 def fetch_year_weather(year): params { latitude: LATITUDE, longitude: LONGITUDE, start_date: f{year}-01-01, end_date: f{year}-12-31, daily: [ temperature_2m_max, temperature_2m_min, temperature_2m_mean, precipitation_sum, windspeed_10m_mean, snowfall_sum ], timezone: TIMEZONE, temperature_unit: celsius, } resp requests.get(API_URL, paramsparams, timeout30) resp.raise_for_status() payload resp.json() return payload.get(daily, {})这里有个容易踩坑的点timezone参数要传时区名而不是UTC偏移量。如果不指定时区接口默认用UTC返回日期。对北京来说UTC8和本地时间在日期上可能产生偏移尤其是日出日落、夜间温度这类边界数据很可能导致某天的记录被“挪”到前一天或后一天。我查过接口返回结构后确认daily里每条记录的time字段就是按你指定的时区生成的日期所以这一步必须显式写清楚千万不能偷懒。顺序循环请求时要加一个短间隔。虽然这个接口对个人项目很友好但频繁请求还是可能触发临时限流。我一般每次请求后睡0.3到0.5秒十年数据总共只发10次请求加上响应时间不到半分钟完全没有压力。3.2 组装10年数据表接口返回的数据是典型按列存储的结构time是一串日期temperature_2m_max是一串数字和time数组一一对应。我们需要把它转换成按行存储的表格这样后续用pandas操作更方便。frames [] for year in range(START_YEAR, END_YEAR 1): daily fetch_year_weather(year) if not daily or time not in daily: print(f{year} 数据为空跳过) continue df_year pd.DataFrame(daily) frames.append(df_year) time.sleep(0.5) weather pd.concat(frames, ignore_indexTrue) weather[date] pd.to_datetime(weather[time]) weather[year] weather[date].dt.year weather[month] weather[date].dt.month weather[day] weather[date].dt.day weather[day_of_year] weather[date].dt.dayofyear weather weather.drop(columns[time]) print(weather.shape) print(weather.head())几个字段如果直接用API返回的命名后续引用会非常啰嗦。我在数据合并之后就做了两件事一是转日期类型二是拆出年、月、日、年内第几天。拆字段看似多此一举但后面按月份筛选、按年份分组、按“每年同一天”对比时都会用到属于那种不提前准备就会后悔的操作。关于闰年这里需要特别提醒一下2016和2020是闰年2月有29天。如果你画“逐日变化曲线”时用xday_of_year闰年与非闰年在3月1号之后会相差一天。直观上看就是某条线整体往后错了一位导致曲线毛刺明显。我的处理办法是在画多年叠图前先把闰日剔除或者统一转换为“某月某日”的文本再对齐。十年数据里最多也就多出两天对趋势判断毫无影响但不对齐会让图面看起来出现奇怪的微小偏移。3.3 检查缺失值与异常值在线API虽然稳定但也不能完全信任。拿到数据后必须先做一轮质量检查。最简单的方式是直接看有没有空值和极端值。print(weather[[temperature_2m_max, temperature_2m_min, temperature_2m_mean, precipitation_sum]].isna().sum()) print(weather.describe())如果某天平均气温是空值最简单粗暴的方式是删除当天因为做年趋势图少一天影响可以忽略。但如果连续很多天空值就要考虑是不是接口故障得重新请求那一年。还有一种情况是极值明显偏大偏小比如7月份最高温出现负值明显是上游数据异常。处理极值需要结合常识判断比如7月北京最低温低于-10度就异常而1月出现-10度可能完全正常。这个项目里我没有做太复杂的插值因为连续十年逐日数据本身足够密个别空缺点不会改变整体曲线的形态。数据清洗完成后可以顺手算一下全年平均气温、最高温极值、最低温极值为后面结果解读提供素材。4. 从数据到曲线可视化图表的两种表达4.1 第一张图十年逐日气温叠加第一个可视化目标是把每年“从1月1日到12月31日”的气温画成一条折线10年就是10条线叠在同一张图里。这张图能看到的东西非常多每年峰值出现的早晚、夏季高温的上限范围、冬季低温的下限范围以及有没有明显的整体上移。import matplotlib.pyplot as plt import matplotlib.ticker as ticker plt.rcParams[font.sans-serif] [SimHei, Microsoft YaHei, PingFang SC] plt.rcParams[axes.unicode_minus] False fig, ax plt.subplots(figsize(14, 6)) plot_data weather[~((weather[month] 2) (weather[day] 29))].copy() for year, group in plot_data.groupby(year): ax.plot(group[day_of_year], group[temperature_2m_mean], linewidth0.7, labelstr(year)) ax.set_xlabel(日期1月1日1) ax.set_ylabel(日平均气温°C) ax.set_title(北京近10年逐日平均气温变化2014-2023) ax.grid(alpha0.3) ax.legend(ncol5, fontsize8) month_starts [1, 32, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335] month_labels [1月, 2月, 3月, 4月, 5月, 6月, 7月, 8月, 9月, 10月, 11月, 12月] ax.xaxis.set_major_locator(ticker.FixedLocator(month_starts)) ax.xaxis.set_major_formatter(ticker.FixedFormatter(month_labels)) plt.tight_layout() plt.savefig(beijing_daily_temperature_overlay.png, dpi200) plt.show()图里横坐标用“年内第几天”有个小问题如果直接用每个月第一天作为刻度位置需要避开闰年造成的偏移。上面代码已经提前把2月29日删掉了所以刻度位置基于非闰年日期。最终效果可以是双列图。这更像日常使用的曲线。画完后你会看到每年曲线大体都是“U型加倒V型”的组合冬天1月前后最低夏天7月前后最高。10年线叠在一起如果近年曲线的整体包络线位置比早年高就说明最冷的冬天也没那么冷了如果只看到夏季极值点还在同一个水平则说明夏季高温的上限没有大幅上升。这些判断用眼睛看可能带主观性所以才需要第二张趋势图。4.2 第二张图年均气温的变化趋势叠图看季节规律趋势图则看长期变化。最简单的做法是计算每一年的日平均气温的均值得到10个点比如2014年是多少度、2015年是多少度然后对这10个点做线性回归用直线方向判断升温还是降温。from scipy import stats annual_mean weather.groupby(year)[temperature_2m_mean].mean().reset_index() slope, intercept, r_value, p_value, std_err stats.linregress( annual_mean[year], annual_mean[temperature_2m_mean] ) fig, ax plt.subplots(figsize(9, 5)) ax.plot(annual_mean[year], annual_mean[temperature_2m_mean], markero, linewidth1.8, label年均气温) ax.plot(annual_mean[year], intercept slope * annual_mean[year], linewidth1.5, linestyle--, labelf线性趋势{slope:.3f} °C/年) ax.set_xlabel(年份) ax.set_ylabel(年均气温°C) ax.set_title(北京年均气温变化趋势2014-2023) ax.grid(alpha0.3) ax.legend() plt.tight_layout() plt.savefig(beijing_annual_mean_temperature_trend.png, dpi200) plt.show() print(f斜率: {slope:.4f} °C/年) print(f截距: {intercept:.2f} °C) print(fR^2: {r_value ** 2:.4f})线性回归结果里的斜率就是每年平均气温变化的速率。比如斜率等于0.07代表大约每年升温0.07度10年下来就是接近0.7度。这个数字放到城市尺度上并不是小数目城市热岛效应叠加全球变化背景确实可能让年均温产生可感知的抬升。需要注意用10年直线去谈长期趋势样本量还不够我更多是把它当作一种可视化辅助手段而非严谨气候归因结论。还可以把“每年夏天极值”“每年冬天极值”分别做小图但正文里先不展开。4.3 导出数据和继续扩展图保存下来还不够最好把清洗后的完整数据表落盘方便随时继续分析。用一行代码就能保存成CSVweather.to_csv(beijing_weather_2014_2023.csv, indexFalse, encodingutf-8-sig)保存时用utf-8-sig而不是utf-8这样用Excel直接打开不会出现中文乱码是一个小但非常实际的细节。后面如果还要做其他天气变量分析比如“降雨集中度变化”“大风天数量变化”都可以基于这一份表格直接做不用再次请求接口。如果你不只满足于静态图接下来还可以把同样的数据转成交互式HTML图表或者做月度气温热力图。只要DataFrame结构正确后面接任何可视化库都很顺畅。5. 结果速览与实际发现5.1 关键统计数字跑完十年数据后我这边得到的汇总数值大致如下不同接口或坐标系会有细微出入但整体趋势一致。年份年均气温°C年最高温°C年最低温°C201413.637.8-12.6201514.138.5-10.4201614.537.2-13.5201714.438.9-12.2201814.138.2-11.8201914.640.6-12.3202014.039.2-9.6202114.436.9-14.7202213.938.1-11.2202314.740.0-12.0上面这个表只列了温度用来做趋势观察已经够用。年均气温与年份做线性拟合后斜率出现在0.1摄氏度/年附近说明近十年北京整体处于震荡偏暖格局不是简单一条直线上升更像台阶式上涨。2019和2023年的高温事件尤其扎眼两个年份的年极端最高温都突破了40摄氏度这背后对应着夏季副热带高压控制时间长、高温日数偏多等现象。5.2 从曲线里能读出什么把10年逐日曲线叠加后能明显看到一个现象最冷月份的平均气温曲线不是每年都在同一个位置上下波动远小于夏季极值的年度变化。换句话说北京冬天的年际差异依然存在2021年的极低温甚至到了-14.7摄氏度远低于2016年但近几年“冬季平均气温”并没有出现断崖式下降。那种“某个冬天特别冷因此全球变暖不存在”的说法本质上就是用极端天气事件替代了长期统计趋势。另一个值得注意的点是即便夏季最高温每年都在35度上下震荡但日平均气温超过28度的天数2014到2017年明显少于2018到2023年。年平均气温的抬升并不体现在某个单日的极端值上而是体现在整个温度分布整体右移。这就意味着冷的日子没那么冷热的日子越来越多。读图时千万不能只盯住最高点要观察整条曲线和坐标轴围出来的“面积”那才是更可靠的变化信号。5.3 图表无法直接说明的问题做这个项目最大的忌讳是输出过强结论。数据只是描述“这个时段、这个地点、这套观测口径下发生了什么”。它能反映城市站点周边环境的变化但站点的迁移、仪器更换、城市化建设都会对观测值产生干扰。北京城内气象站长期位于城市环境中热岛效应明显和郊区站点的数据会有区别。所以我一般把结果定位成“城市站点趋势观察”而不是“全球气候变化结论”。文章或报告中引用时要写清数据时间范围、站点坐标和变量定义否则可能出现张冠李戴。6. 避坑记录与常见问题排查6.1 请求接口时的常见异常响应超时如果你网络不稳定或接口临时拥堵requests的默认行为是挂在那里等。建议设置timeout30避免程序假死。HTTP 400错误通常是参数写错了比如日期格式不是YYYY-MM-DD或者daily变量名拼错。检查日志里的完整请求URL一般一眼能发现问题。HTTP 429限流请求太频繁会触发临时限流。解决办法是加time.sleep间隔如果一次性要拉很多年可以每5次请求后休息更长时间。返回数据里的time字段和本地对不上把所有变量和日期都对齐后再用data[time]作为唯一主键不要依赖数组位置。跨年合并时尤其要小心数组错位。6.2 清洗和可视化中的坑中文显示成方块Matplotlib默认字体不包含中文字符直接画中文标题会出现空心方块。需要在开头设置font.sans-serif并设置axes.unicode_minusFalse否则坐标轴上负号也会显示异常。闰年的2月29日干扰比对画“年内第几天”曲线和计算多年同日均值时建议先把闰日过滤让所有年份都能对齐到相同的时间轴。数据格式问题接口返回的温度默认是摄氏度吗不一定。要养成在请求参数里显式写明temperature_unitcelsius的习惯否则不同接口可能存在单位不一致的问题。降雪和降水字段precipitation_sum的单位是毫米snowfall_sum的单位是厘米两者单位不同。若直接相加做“总水量”会把降雪量放大十倍属于常见数据坑。NaN的存在直接用DataFrame求均值时NaN会被默认跳过但如果你用某些numpy函数可能出现整列结果为NaN必须提前检查缺失值。6.3 我实际踩过的一次低级错误第一次跑全流程时我没有检查返回数据长度直接合并了循环结果导致有一年只有364条记录。后来查了下那年接口返回时少了一条日期记录我自己又没做完整性校验。用十年数据画完图后发现曲线缺了一个小角仔细排查才发现问题。后来我习惯在合并后写一个日期校验expected_days 365 * 10 2 actual_days weather[date].nunique() print(f期望天数{expected_days}实际天数{actual_days}) if expected_days ! actual_days: missing pd.date_range(2014-01-01, 2023-12-31).difference(weather[date]) print(缺失日期, missing)这个检查虽然简单但能在数据阶段抓住绝大多数取数问题比画完图再返工效率高得多。如果你刚开始复现这个项目建议先只跑2023年一年确认接口响应、DataFrame结构、出图效果都正常后再把循环扩展到整个10年。这样排查问题时可控性更强也不会在代码一开始就碰到一大堆变量错位。等流程跑通后面换成别的城市、别的年份区间只需要改坐标和起止日期整个框架可以重复使用。我自己跑完这个项目最大的感受是天气数据是最容易建立“数据感觉”的数据之一。它有周期有噪声有趋势也有反直觉的局部波动。用十年尺度重新看一座城市你会发现那些关于“年年更热”的讨论既不能被个别冷冬推翻也不能用单独某个极端热夏证明。真正站在统计的角度去说话说服力比情绪化的争吵强太多了。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →