简介:这份资源是面向深度学习初学者与气象数据分析爱好者的实战项目包,围绕CNN与LSTM结合的混合模型实现小时级天气预测。项目利用CNN提取气象图像的空间特征,再交由LSTM建模时间序列依赖,最终输出未来天气状况,适合作为课程设计、毕业项目或算法练手的参考模板。压缩包共27个文件,包含8个Python脚本、11张jpg与4张png结果图、2份md说明文档及1个csv数据集,整体约1.11MB,代码覆盖数据预处理、模型定义、训练验证与可视化评估等环节。目前已有249人学习下载。读者可借此理解CNN-A-LSTM的搭建思路与调参流程,对照损失曲线和预测对比图排查问题,并基于Houston.csv替换自有数据快速复现实验,文档说明则补充了项目背景与使用指引。
1. 从一份 CNN-A-LSTM 源码包说起:小时级天气预测到底难在哪
小时级天气预测和常见的「明天多云转晴」不是一回事。日级预报看的是大尺度环流,几小时内的温度、湿度、风速变化却由局地湍流、太阳辐射日变化、近地面热惯性主导,规律更碎、更依赖高频观测序列。这也是为什么很多人拿到一份「基于 CNN-A-LSTM 的小时天气预测 Python 源码+文档说明」的压缩包,跑完 demo 发现曲线挺漂亮,一换自己的数据就崩——问题往往不在模型,而在序列构造、特征对齐和评估口径上。
CNN-A-LSTM 这个组合的思路其实很直白:CNN 负责在时间窗口内提取局部变化模式(比如连续几小时的气压陡降、湿度爬升),LSTM 负责把这些局部特征按时间顺序串起来,捕捉更长程的依赖。它比纯 LSTM 多了一层局部特征抽取,比纯 CNN 多了一层时序记忆,对小时级这种「局部突变 + 缓慢趋势」混合的信号比较合适。这份源码包面向的是想用 Python 快速搭一套可复现预测流程的人:有数据、想验证方法、想改成自己的站点或传感器序列。下面我按「数据怎么组织 → 模型怎么搭 → 怎么训 → 坑在哪 → 怎么验证」把这条路走一遍,参数和代码都能直接抄。
2. 数据准备与序列构造:小时天气预测的地基
2.1 先想清楚预测目标,再决定输入输出形状
动手写代码前必须先定死三件事:预测哪个变量、预测未来几步、用过去多长窗口。这三者决定了整个数据管道的形状,改一个就要重跑全部。
常见做法是单变量单步:用过去 24 小时的温度,预测未来 1 小时温度。但小时天气预测里更实用的是多变量多步,比如用过去 24 小时的气温、湿度、气压、风速、露点,预测未来 6 小时的气温。多变量能显著提升精度,因为气压和湿度对温度的短时变化有很强的解释力。
| 参数 | 含义 | 常用取值 | 影响 |
|---|---|---|---|
| input_window | 输入历史步数 | 24 / 48 | 太短抓不到日变化,太长引入噪声 |
| output_window | 预测未来步数 | 1 / 6 / 12 | 步数越多误差累积越明显 |
| stride | 滑窗步长 | 1 | 训练样本量 = 总长 - 窗口 + 1 |
| features | 输入特征数 | 1~8 | 多变量通常优于单变量 |
| target_idx | 目标变量列索引 | 0 | 决定反归一化用哪一列 |
我一般先用 24 进 1 出把流程跑通,确认没有形状错误和数据泄漏,再扩到 24 进 6 出。一上来就上多步,调试成本会翻倍。
2.2 用滑动窗口把时间序列切成监督样本
原始数据是一维时间序列,模型要的是 (样本数, 时间步, 特征数) 的三维张量。滑动窗口是标准做法,但顺序和归一化的位置最容易出错。
import numpy as np import pandas as pd from sklearn.preprocessing import MinMaxScaler def load_and_scale(csv_path, feature_cols): df = pd.read_csv(csv_path, parse_dates=["time"]).sort_values("time") df = df[feature_cols].astype("float32") # 前向填充处理短缺口,长缺口应直接丢弃而不是插值 df = df.ffill(limit=3).dropna() scaler = MinMaxScaler(feature_range=(0, 1)) scaled = scaler.fit_transform(df.values) return scaled, scaler def make_windows(data, input_window=24, output_window=1, target_idx=0): X, y = [], [] total = input_window + output_window for i in range(len(data) - total + 1): X.append(data[i:i + input_window]) y.append(data[i + input_window:i + total, target_idx]) X = np.array(X, dtype="float32") y = np.array(y, dtype="float32") return X, y逻辑说明:load_and_scale先按时间排序,再做前向填充,最后整体归一化。make_windows用固定长度窗口切样本,X 是过去input_window步的全部特征,y 是紧接着output_window步的目标变量。
参数说明:limit=3表示最多连续填 3 个缺失点,超过就丢弃整段,避免用插值伪造出并不存在的天气过程。target_idx=0要和feature_cols里目标变量的位置一致,否则反归一化会取错列,预测值会整体偏移。
注意:归一化必须在切窗口之前、且只用训练集 fit。如果先切窗口再对全体数据 fit,测试集信息会泄漏进训练,验证指标会虚高,上线就翻车。
2.3 训练集、验证集、测试集要按时间切,不能随机打乱
时间序列最忌讳train_test_split(shuffle=True)。随机打乱会让未来样本混进训练集,模型等于偷看了答案。正确做法是按时间顺序切:前 70% 训练,中间 15% 验证,最后 15% 测试。
def temporal_split(X, y, ratios=(0.7, 0.15, 0.15)): n = len(X) n_train = int(n * ratios[0]) n_val = int(n * ratios[1]) X_train, y_train = X[:n_train], y[:n_train] X_val, y_val = X[n_train:n_train + n_val], y[n_train:n_train + n_val] X_test, y_test = X[n_train + n_val:], y[n_train + n_val:] return (X_train, y_train), (X_val, y_val), (X_test, y_test)逻辑说明:按索引顺序切分,保证训练集时间早于验证集,验证集早于测试集,模拟真实预测场景。
参数说明:ratios三个数之和必须为 1。数据量少于 2000 条时,验证集可以压到 10%,把更多数据留给训练,但测试集不能省,否则没有可信的最终指标。
这一步做完,数据管道就定型了。后面模型再花哨,如果这里泄漏或错位,结果都不可信。
3. CNN-A-LSTM 模型搭建:从 Conv1D 到 LSTM 的通道设计
3.1 为什么是 CNN 接 LSTM,而不是反过来
顺序不能反。CNN 的卷积核在时间维上滑动,提取的是局部片段特征,输出仍是时间序列,只是通道数变了。LSTM 需要的是「带时间顺序的特征序列」,所以 CNN 在前做特征提取、LSTM 在后做时序建模是自然的。
如果反过来先 LSTM 再 CNN,LSTM 输出的隐状态序列虽然也有时间维,但 CNN 会在已经高度抽象、且长度被压缩的表示上再做局部卷积,收益很小,还容易过拟合。常见做法就是 Conv1D 堆一两层,接 LSTM,再接全连接输出。
import torch import torch.nn as nn class CNNALSTM(nn.Module): def __init__(self, n_features, input_window, output_window, conv_channels=64, kernel_size=3, lstm_hidden=64, lstm_layers=1, dropout=0.2): super().__init__() self.conv = nn.Sequential( nn.Conv1d(n_features, conv_channels, kernel_size, padding=kernel_size // 2), nn.ReLU(), nn.Conv1d(conv_channels, conv_channels, kernel_size, padding=kernel_size // 2), nn.ReLU(), ) self.lstm = nn.LSTM(conv_channels, lstm_hidden, lstm_layers, batch_first=True, dropout=dropout if lstm_layers > 1 else 0.0) self.dropout = nn.Dropout(dropout) self.fc = nn.Linear(lstm_hidden, output_window) def forward(self, x): # x: (batch, input_window, n_features) -> Conv1d 需要 (batch, n_features, input_window) x = x.transpose(1, 2) x = self.conv(x) x = x.transpose(1, 2) # 回到 (batch, input_window, conv_channels) out, _ = self.lstm(x) last = out[:, -1, :] # 取最后时间步的隐状态 return self.fc(self.dropout(last))逻辑说明:两次transpose是为了在 Conv1d 的通道维和 LSTM 的时间维之间切换。padding=kernel_size // 2保证卷积后时间长度不变,LSTM 才能拿到完整序列。取out[:, -1, :]表示用最后一个时间步的隐状态做预测,适合单步输出;多步输出时全连接层直接映射到output_window维。
参数说明:conv_channels=64是特征通道数,数据量小可以降到 32;kernel_size=3覆盖 3 小时局部模式,想抓更长突变可试 5;lstm_hidden=64是记忆容量,超过 128 在小数据集上基本过拟合;dropout=0.2是正则,LSTM 层数大于 1 时才在层间生效。
3.2 训练循环里必须盯住的三个量
训练不是把 loss 跑下去就完事。小时天气预测里,我固定盯三个量:训练 loss、验证 loss、以及验证集上的 MAE(反归一化后)。前两个看是否过拟合,第三个看真实误差量级。
def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total = 0.0 for xb, yb in loader: xb, yb = xb.to(device), yb.to(device) optimizer.zero_grad() pred = model(xb) loss = criterion(pred, yb) loss.backward() # 梯度裁剪,LSTM 梯度爆炸的后悔药 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() total += loss.item() * xb.size(0) return total / len(loader.dataset) @torch.no_grad() def evaluate(model, loader, criterion, device): model.eval() total = 0.0 for xb, yb in loader: xb, yb = xb.to(device), yb.to(device) total += criterion(model(xb), yb).item() * xb.size(0) return total / len(loader.dataset)逻辑说明:clip_grad_norm_把梯度范数限制在 1.0,防止 LSTM 在长序列上梯度爆炸。评估函数用torch.no_grad()关闭梯度,省显存也更快。
参数说明:max_norm=1.0是经验值,梯度经常爆可以降到 0.5;优化器我一般用 Adam,学习率 1e-3,验证 loss 连续 5 个 epoch 不降就乘 0.5。
注意:验证 loss 用 MSE 时对异常值敏感,如果数据里有极端天气样本,MSE 会被拉高,看起来像过拟合。这时换成 MAE 或 Huber 更稳。
3.3 反归一化:预测值回到真实量纲才算数
模型输出是 0~1 之间的数,必须用训练集的 scaler 反变换回摄氏度。多变量时只反变换目标列,不能整行反变换。
def inverse_target(scaled_pred, scaler, target_idx, n_features): # scaled_pred: (n_samples, output_window) dummy = np.zeros((scaled_pred.shape[0], n_features), dtype="float32") dummy[:, target_idx] = scaled_pred[:, 0] inv = scaler.inverse_transform(dummy)[:, target_idx] return inv逻辑说明:inverse_transform要求输入维度和 fit 时一致,所以先造一个同宽的全零矩阵,把预测值填进目标列,反变换后只取目标列。
参数说明:target_idx必须和make_windows里一致;n_features是训练时的特征总数。单步预测时scaled_pred[:, 0]就是那一步,多步则要循环每一列。
到这里模型能跑通了。但真正决定成败的,是下面这些坑。
4. 避坑与排查:小时天气预测最常见的 5 个翻车点
4.1 现象:验证 loss 一路下降,测试集 MAE 却大得离谱
原因:数据泄漏。最常见的是归一化用了全体数据,或者切分时随机打乱,让未来信息进了训练。另一个隐蔽来源是缺失值插值——用整段均值填充,等于把未来统计量带进了过去。
解决:归一化只在训练集 fit,切分严格按时间,缺失值优先丢弃而非插值。改完后重跑,测试 MAE 通常会回到合理区间。如果还是异常,检查特征里有没有包含目标变量的未来值(比如「未来 1 小时温度」这种列被误当输入)。
4.2 现象:训练 loss 震荡剧烈,偶尔变成 NaN
原因:学习率过大,或 LSTM 梯度爆炸。小时序列里如果有极端值(比如寒潮导致温度骤降十几度),归一化后仍可能产生大梯度。
解决:加梯度裁剪(clip_grad_norm_),学习率从 1e-3 降到 5e-4 或 1e-4。如果 NaN 出现在第一个 batch,检查输入里有没有 NaN 或 inf,dropna之后还要np.isfinite兜底。
4.3 现象:模型预测曲线整体平移,形状对但数值差一个常数
原因:反归一化取错了列,或者 scaler 的feature_range和训练时不一致。多变量场景下,目标列索引写错一位就会整体偏移。
解决:打印scaler.data_min_和data_max_,确认目标列的范围和原始数据一致。反归一化后取前 5 个预测值和真实值对比,数值量级对不上就是这里的问题。
4.4 现象:单步预测很准,多步预测从第 3 步开始崩
原因:多步输出时,全连接层直接映射到output_window维,各步之间没有依赖约束,误差独立累积。另外训练时多步 loss 被大步数样本主导,短步精度被牺牲。
解决:改用自回归方式,每次预测一步再喂回输入;或者对每一步单独算 loss 再加权,近期步权重更高。常见做法是loss = sum(w_i * mse(pred_i, y_i)),w_i随步数递减。
4.5 现象:换一个站点或季节,模型精度断崖式下跌
原因:小时天气有强季节性和站点差异,用夏季数据训的模型直接套冬季,温度分布完全变了,归一化范围也不匹配。
解决:按季节分别建模,或在特征里加入时间编码(小时、月份的正余弦)。跨站点时重新 fit scaler,不要复用。如果数据够,做迁移学习:冻结 CNN 层,只微调 LSTM 和全连接。
这五条基本覆盖了从数据到部署的主要翻车点。排查顺序建议从数据泄漏开始,再查梯度,最后查反归一化,因为前两个影响最大且最难发现。
5. 验证与进阶:让小时预测真正可用的两个技巧
模型训完,别急着看 loss 曲线自我感动。真正能说明问题的是两件事:一是和朴素基线比,二是看误差在一天中的分布。
朴素基线很简单:用「当前时刻的值」直接当「下一时刻的预测」。如果 CNN-A-LSTM 连这个都赢不了,说明模型没学到东西。我一般会算三个基线:persistence(持续法)、滑动平均、以及一个纯 LSTM。CNN-A-LSTM 至少要稳定优于 persistence 和纯 LSTM,才值得继续投入。
def persistence_baseline(X, y, target_idx=0): # 用输入窗口最后一个时间步的目标值作为预测 return X[:, -1, target_idx] def mae(pred, true): return np.mean(np.abs(pred - true)) # 对比 base_pred = persistence_baseline(X_test, y_test) model_pred = inverse_target(model(X_test_tensor).cpu().numpy(), scaler, 0, n_features) print("persistence MAE:", mae(base_pred, y_test[:, 0])) print("cnn-a-lstm MAE:", mae(model_pred, y_test[:, 0]))逻辑说明:persistence_baseline直接取窗口最后一步的目标值,是最朴素的预测。对比 MAE 能快速判断模型是否有增量价值。
参数说明:target_idx要和训练一致;y_test[:, 0]取多步输出的第一步做对比,多步时逐步对比。
第二个技巧是按小时分段看误差。小时天气预测的误差不是均匀分布的,通常在日出前后和傍晚降温时段最大,因为这两个时段温度变化最快,模型最难跟。把测试集按小时分组算 MAE,画出来,你会看到明显的双峰。针对误差大的时段,可以单独加特征(比如太阳高度角、小时的正余弦编码),或者对这些时段样本加权。
test_hours = df["time"].dt.hour.values[-len(y_test):] for h in range(24): mask = test_hours == h if mask.sum() > 0: print(f"hour {h:02d} MAE: {mae(model_pred[mask], y_test[mask, 0]):.3f}")逻辑说明:按小时分组算 MAE,定位模型在哪些时段最弱。
参数说明:test_hours要和测试集样本对齐,注意窗口切分后样本数会减少,索引要同步偏移。
我自己踩过最深的一个坑,是早期图省事把归一化放在切窗口之后,验证集 MAE 只有 0.8 度,兴奋了半天,上线一跑实际误差 3 度多。后来养成习惯:任何时间序列项目,先写一个「数据泄漏自检」脚本,确认训练集时间戳全部早于验证集,再动模型。这个习惯比任何调参技巧都值钱。希望帮到你。
本文还有配套的精品资源,点击获取