
简介这是一份面向水文预报及机器学习方向学习者的径流预测项目整合了人工神经网络、随机森林与长短期记忆网络三类模型利用实测径流数据进行训练与评估并通过数据可视化脚本完成结果对比分析。项目代码已测试运行适合作为计算机、人工智能、自动化等专业的毕业设计或课程设计参考也便于初学者在已有框架上修改扩展。压缩包共107个文件内含7个Python源程序、1个Jupyter笔记、3个径流数据集、12个模型权重文件以及74张评价指标图整体约4.97MB按说明文档即可快速了解目录结构。资源中还包括多组模型误差与拟合指标可视化结果可直观比较不同模型在径流预测中的表现。目前已有124人学习下载对相关方向的研究者或学生而言是一份代码、数据与结果兼具的完整参考。1. 径流预测不是黑匣子人工神经网络、随机森林、LSTM谁更适合你的站点做径流预测这件事传统水文模型新安江、SWAT、HBV一类依然是工程主流但它的参数率定成本和数据门槛都不低。尤其当你手里只有一个站点、日尺度的历史流量和降水记录没有蒸散发观测、没有土壤墒情资料时分布式模型的边界条件根本凑不齐。这时候机器学习模型的优势就出来了人工神经网络、随机森林、LSTM这三类模型只需要流量和降水两列数据就能训练出一个多步预测器精度在平原中小流域往往不输传统概念模型。这个项目把三类模型放在同一份数据上用统一的样本切分和评价指标做横向对比源码和参数配置都打包在一起。适合水文专业学生、基层水利工作者以及想拿一份完整时序预测案例练手的 Python 从业者。你不需要有水文模型基础需要的是能读懂 Pandas 和 PyTorch 代码。2. 数据预处理与滑窗构造这七成工作直接决定模型上限2.1 拿到一份径流数据先做什么检查项目原始数据是典型的站点日尺度观测表结构如下字段类型说明datedatetime日期必须连续无跳跃flowfloat日平均流量单位 m³/sprecipfloat日降水总量单位 mmevapfloat日蒸散发量可选单位 mm拿到数据后先做三件事检查日期连续性、统计流量缺失率、画出流量过程线。日期跳跃和缺失值是水文数据最常见的坑缺测超过 5% 的年份建议直接剔除而不是插补线性插值只适合填补零星几天。流量过程线能一眼看出站点是受降水主导还是受融雪/地下水补给主导这决定了后面的滑窗长度。我一般会用 Pandas 先跑一遍数据体检把缺失率大于阈值的年份打上标记。2.2 构造监督学习样本滑窗与滞后特征径流预测在机器学习视角下本质是回归问题用前 n 天的已知观测去预测未来第 t1 天的流量。关键在于怎么把时序表转成监督学习样本常见做法是用滚动滑窗。import pandas as pd import numpy as np def make_samples(df, lookback7, target_colflow, feature_colsNone): 把日径流时序转成监督学习样本 df: 包含date/flow/precip的DataFrame按日期升序 lookback: 回看窗口长度天 feature_cols: 参与预测的特征列默认[flow,precip] if feature_cols is None: feature_cols [flow, precip] X, y [], [] data df[feature_cols].values for i in range(lookback, len(df)): X.append(data[i - lookback:i]) # 前lookback天的特征 y.append(data[i, 0]) # 第i天的流量作为目标 return np.array(X), np.array(y)这段代码的逻辑是遍历整条时间序列每次截取长度为 lookback 的窗口作为输入特征窗口后一天的目标值作为输出。参数 lookback 的选择非常关键对日径流来说 7 天能捕捉一周尺度的降水响应15 天适合滞时较长的流域30 天则考虑土壤湿度记忆效应。如果流域面积大、汇流时间长lookback 要相应加长小流域取 3 到 7 天就够了。特征列里 target 必须放在第一列方便后面滚动预测时替换。2.3 归一化与切分时序预测里最大的暗坑接下来是很多人翻车的地方。模型训练前必须归一化因为 LSTM 内部使用 tanh 和 sigmoid 激活函数未归一化的流量值几百 m³/s直接进网络会让梯度爆炸。归一化方法推荐 MinMaxScaler 而不是 StandardScaler因为流量数据经常含零值且分布右偏标准化后零值区间会被压缩。更关键的是切分顺序。分类任务可以随机打乱样本时序预测绝对不能用随机 split否则相邻样本互相包含对方的滞后值验证集等于直接抄了训练集的答案。def temporal_split(X, y, train_ratio0.7, val_ratio0.15): n len(X) train_end int(n * train_ratio) val_end int(n * (train_ratio val_ratio)) return (X[:train_end], y[:train_end]), \ (X[train_end:val_end], y[train_end:val_end]), \ (X[val_end:], y[val_end:])切分比例按时间顺序前 70% 训练、中间 15% 验证、最后 15% 测试。验证集用来选超参测试集必须从头到尾不参与调参这样才能反映模型对未来真实未知数据的泛化能力。顺序切分会导致训练集和测试集的流量分布不完全一致比如测试期恰好是丰水年这不是 bug而是水文数据本身的非平稳性后文我会专门讲怎么应对。热量词“随机森林需要跑多长时间”在模型选型那节我会给实测参考。数据准备的最后一步是把归一化器单独保存训练集用训练集的统计量 fit验证集和测试集只做 transform。很多线上系统模型效果退化就是因为在离线训练时把全数据集一起做了归一化导致部署时新数据进来尺度对不上。3. 三种模型原理与选型边界先从结构上理解再做对比表3.1 人工神经网络非线性映射的底子人工神经网络在这个项目里指的是最经典的全连接前馈网络多层感知机MLP。它的结构是输入层接若干隐藏层再接输出层每层之间通过权重矩阵和激活函数做非线性变换。MLP 没有时间记忆结构所有时序信息都必须通过滑窗特征手工喂进去窗口外的历史信息它完全感知不到。对径流预测来说MLP 适合样本量不大、特征维度不高、非线性关系明显的场景。比如只有三五年资料的山区小流域MLP 往往比 LSTM 更稳因为 LSTM 在数据量不足时很难学出门控权重。MLP 的隐藏层深度超过三层收益就急剧下降我一般用两层隐藏层、每层 64 个神经元配合 ReLU 激活和 Dropout 防过拟合。3.2 随机森林回归算法不用调归一化但需要手工给时间记忆随机森林是 Bagging 策略下的决策树集成每棵树在训练集和特征集上做随机抽样最终预测值取多棵树平均。径流预测中它的核心竞争力有三点训练速度快得惊人几千个样本几十秒就能跑完完全不需要担心“随机森林需要跑多长时间”这类问题对特征量纲不敏感不归一化也能训练能输出特征重要性直接告诉你前几天的降水和流量对预测的贡献排序。但随机森林的时间感知能力为零。它把每个滑窗样本当作独立个体不知道样本之间的先后关系。如果你只给当天降水作为特征模型永远学不会径流滞后效应。正确的做法是把滞后特征手工构造好除了前 lookback 天流量和降水还可以加入 3 日累计降水、7 日累计降水、基流指数这类物理意义明确的衍生特征。def make_rf_features(df, lookback7): df df.copy() # 滞后流量 for lag in range(1, lookback 1): df[fflow_lag{lag}] df[flow].shift(lag) # 多日累计降水捕捉流域蓄满产流效应 df[precip_3d] df[precip].rolling(3).sum() df[precip_7d] df[precip].rolling(7).sum() # 基流指数近30天流量中位数 df[base_flow] df[flow].rolling(30).median() df df.dropna() labels df[flow].values features df.drop(columns[date, flow]).values return features, labels这份代码体现了随机森林特征工程的关键流量滞后项负责提供序列记忆累计降水项负责表征流域蓄水量状态30 天中位数粗估基流。参数方面随机森林对树的数量不敏感500 棵和 2000 棵的差异很小但 max_features 建议设成特征总数的三分之一min_samples_leaf 设 5 左右防止叶子节点过细拟合噪声。3.3 LSTM记住半年前的干旱再预测今天的洪水LSTM 是循环神经网络的一个变体核心是引入了细胞状态和三个门控结构遗忘门决定丢弃多少旧信息输入门决定写入多少新信息输出门决定输出什么信息。在径流预测场景下这种机制的价值在于它能自己学习到“半年前降水偏少导致土壤蓄水不足今天的强降水应该产流量小”这类长周期依赖而 MLP 和随机森林很难显式构造这样的关系。代价是 LSTM 对数据量和超参都很挑剔。几千样本能跑但容易过拟合上百个 epoch 训练过程不稳定学习率稍大 loss 直接变成 NaN隐藏层数和 dropout 的搭配需要反复试。项目里 LSTM 的默认参数是两层隐藏层、每层 64 单元、dropout 0.2、学习率 0.001这是一个对大多数日径流数据都还算友好的起点。需要注意的是输入数据必须 reshape 成三维形状 [样本数, 时间步长, 特征数]即 [samples, lookback, features]batch_firstTrue 时第一维是 batch。3.4 选型一张表维度人工神经网络 (MLP)随机森林 (RF)LSTM时间记忆无靠滑窗人工喂无靠滞后特征人工构造有门控结构自动学习数据量要求低几百样本可训低几百样本可训高最好五千样本以上训练速度快最快慢GPU 数分钟起是否需要归一化是否是特征重要性输出无有无可解释性差中差对非平稳数据的鲁棒性中高中这张表是我选型时的核心依据。样本少选 RF 或 MLP样本充裕且需要捕捉长程依赖选 LSTM需要向领导解释特征贡献时选随机森林。径流预测没有绝对的“最好模型”只有适合你手头数据特征的模型。4. 跑通LSTM径流预测的完整Python实现代码、参数与评估指标4.1 从csv到训练样本数据Loader怎么写import pandas as pd import numpy as np from sklearn.preprocessing import MinMaxScaler import torch from torch.utils.data import TensorDataset, DataLoader # 读取原始数据 df pd.read_csv(station_01.csv, parse_dates[date]).sort_values(date) df df.set_index(date) # 只保留建模需要的两列 feature_cols [flow, precip] data df[feature_cols].dropna() # 构造监督学习样本 def make_samples(arr, lookback7): X, y [], [] for i in range(lookback, len(arr)): X.append(arr[i-lookback:i]) y.append(arr[i, 0]) return np.array(X), np.array(y) # 时序切分先切分后归一化避免数据泄漏 n len(data) train_end int(n * 0.7) val_end int(n * 0.85) train_raw data.iloc[:train_end].values val_raw data.iloc[train_end:val_end].values test_raw data.iloc[val_end:].values # 只在训练集上做归一化 scaler MinMaxScaler() train_scaled scaler.fit_transform(train_raw) val_scaled scaler.transform(val_raw) test_scaled scaler.transform(test_raw) X_train, y_train make_samples(train_scaled, lookback7) X_val, y_val make_samples(val_scaled, lookback7) X_test, y_test make_samples(test_scaled, lookback7) # 转成PyTorch Tensor并封装为DataLoader train_dataset TensorDataset(torch.tensor(X_train, dtypetorch.float32), torch.tensor(y_train, dtypetorch.float32)) train_loader DataLoader(train_dataset, batch_size64, shuffleFalse)逻辑上这段代码把数据流程串成了四段读取排序、滑窗构造、时序切分、归一化封装。注意归一化只用训练集的统计量 fit验证集和测试集只做 transform这是防止数据泄漏的底线。DataLoader 里 shuffle 参数设为 False时序数据在 epoch 内部不应该被打乱虽然 LSTM 的每个样本是独立窗口乱序对单步预测影响不大但对滚动预测场景会有隐患。4.2 LSTM模型定义与训练流程import torch.nn as nn class LSTMModel(nn.Module): def __init__(self, input_size2, hidden_size64, num_layers2, output_size1, dropout0.2): super().__init__() self.lstm nn.LSTM(input_size, hidden_size, num_layers, batch_firstTrue, dropoutdropout) self.fc nn.Linear(hidden_size, output_size) def forward(self, x): out, _ self.lstm(x) out out[:, -1, :] return self.fc(out) model LSTMModel(input_size2, hidden_size64, num_layers2, output_size1, dropout0.2) criterion nn.MSELoss() optimizer torch.optim.Adam(model.parameters(), lr0.001) epochs 200 for epoch in range(epochs): model.train() train_loss 0.0 for batch_x, batch_y in train_loader: optimizer.zero_grad() pred model(batch_x).squeeze() loss criterion(pred, batch_y) loss.backward() optimizer.step() train_loss loss.item() * batch_x.size(0) if (epoch 1) % 20 0: print(fEpoch {epoch1}: loss {train_loss/len(train_dataset):.6f})LSTM 核心参数是 hidden_size64 和 num_layers2。hidden_size 决定记忆容量64 在中小流域够用数据量大时可以提到 128num_layers 超过 3 层在小数据集上基本只会过拟合因为梯度穿过太多层之后回传信号已经衰减。dropout 只作用于除最后一层外的输出这里取 0.2。学习率 0.001 是 Adam 优化器在回归任务上的稳妥默认值如果 loss 震荡不收敛优先降到 0.0003而不是调网络宽度。4.3 评估、保存与单步滚动预测# Nash-Sutcliffe效率系数水文模型最常用的评价指标 def nse(obs, sim): obs np.array(obs); sim np.array(sim) return 1 - np.sum((obs - sim) ** 2) / np.sum((obs - np.mean(obs)) ** 2) model.eval() with torch.no_grad(): pred_test model(torch.tensor(X_test, dtypetorch.float32)).squeeze().numpy() # 把归一化后的预测值还原回m³/s flow_scaler MinMaxScaler() # 注意这里要用测试集的真实流量做反归一化实际工程中应保存训练时的scaler pred_flow scaler.inverse_transform(np.column_stack([pred_test, np.zeros_like(pred_test)]))[:, 0] obs_flow scaler.inverse_transform(np.column_stack([y_test, np.zeros_like(y_test)]))[:, 0] print(fNSE {nse(obs_flow, pred_flow):.3f}) print(fRMSE {np.sqrt(np.mean((obs_flow - pred_flow)**2)):.2f} m³/s) # 保存模型 torch.save(model.state_dict(), lstm_runoff.pt)反归一化这里我故意写得比较绕因为 MinMaxScaler 是对二维特征矩阵做的所以必须构造一个和原始特征同样列数的矩阵再逆变换。实际工程中正确的做法是在训练完就单独保存 scaler 对象用 joblib.dump部署时加载同一个 scaler 做 transform而不是用测试集重新 fit。NSE 的取值范围从负无穷到 1大于 0.65 就算模型有效大于 0.85 属于优秀如果出现负值大概率是代码逻辑或数据预处理有问题。滚动预测是另一个常见需求用前 lookback 天预测下一天然后把预测值拼进窗口继续预测下一天。def recursive_predict(model, last_window, steps, scaler): last_window: 最后一个真实滑窗形状 [lookback, features] steps: 要预测未来多少天 model.eval() preds [] current last_window.copy() with torch.no_grad(): for _ in range(steps): x torch.tensor(current, dtypetorch.float32).unsqueeze(0) pred model(x).item() preds.append(pred) # 窗口整体前移流量位置用预测值填充 current np.roll(current, -1, axis0) current[-1] current[-1] * 0 # 先清空该行 current[-1, 0] pred # 第一个特征位填预测流量 # 反归一化 preds np.array(preds).reshape(-1, 1) dummy np.zeros((len(preds), scaler.scale_.shape[0])) dummy[:, 0] preds[:, 0] return scaler.inverse_transform(dummy)[:, 0]滚动预测有个现实问题预测步数越长误差累积越严重预测值会逐渐向均值回归。这是循环神经网络在递归预测时的通病不是代码 bug。所以工程上一般只做 7 天以内的滚动预测更长时间用直接多步输出让模型一次输出未来 7 天的向量效果更好。项目源码里两种模式都有默认跑的是单步评估加 7 日滚动预测。5. 避坑记录五个能让NSE直接变负数的常见错误5.1 示意图随机打乱样本验证集指标虚高现象测试集 NSE 高达 0.93看起来模型完美但把预测结果画成过程线之后发现预测曲线比实测曲线整体滞后了一天根本是拿昨天的流量平移着报。原因代码里用了 sklearn 的 train_test_split默认 shuffleTrue。水文序列有极强的自相关性相邻样本的滑窗高度重叠训练集里出现的窗口在测试集里几乎原样复现模型等于直接背答案。解决强制用时间顺序切分train_test_split 里的 shuffle 参数设为 False或者干脆写成前文那种切分函数先定索引再取数据。从那以后我所有水文建模的代码规范里都写着同一句话时序数据永远禁止随机切分。5.2 归一化泄漏全数据集fit后训练集测试集指标都假好现象训练集和测试集的 NSE 都很好但模型部署到新站点后发现完全失效预测值全都落在一个很窄的区间内。原因在切分之前对整个数据集调用了 fit_transform测试期的最大值和最小值已经被模型看见。泛化评估时测试集本来就应该是一个“未来未知”的状态你提前让它贡献了归一化参数相当于考试前偷看了标准答案。这种做法在单站评估时骗过了指标换个站点就露馅。解决切分之后只在训练集上 fit验证集测试集只 transform。我通常会写一个 Pipeline 把数据切分和归一化串起来确保不会漏掉这一步。5.3 Loss变成NaN的翻车现场现象训练到第 20 个 epochloss 从 0.01 突然打印成 nan后续所有 epoch 的 loss 都是 nan。原因最常见的是学习率过大导致梯度爆炸尤其是在流量数据里有异常大的极端值比如台风暴雨下的洪水记录时归一化后仍然有离群点反向传播时梯度被异常值放大。另一个原因是 LSTM 里忘记设置 dropout 的位置导致梯度在跨层回传时累积。解决先看数据里有没有极端值流量超过 99.9% 分位数的样本考虑裁剪再把学习率从 0.01 降到 0.0003最后在 LSTM 层设置 clip_grad_norm_把梯度范数限制在 1.0 以内。这套组合拳基本能救回九成以上的 NaN 场景。5.4 洪水峰值系统性偏低现象枯季流量预测得相当准但一到汛期洪峰预测值只有实测峰值的六成整个汛期的 NSE 被拉低到 0.5 以下。原因损失函数用的是 MSE它对大流量值的惩罚天然比对小流量值更敏感理论上模型会更关注大流量。但在样本分布上枯季样本远多于汛期样本模型整体偏向拟合多数样本洪水样本被淹没。加上 LSTM 的门控机制偏向保守输出峰值自然被削平。解决常用做法是对数变换目标变量log flow再训练或者给损失函数加权重让大流量样本的 loss 占比更高。更直接的办法是训练后做峰值校准建立一个峰值误差的统计修正模型用历史洪峰误差的平均比例去放大预测峰值。我在项目里实测过log1p 变换能让洪峰 NSE 平均提高 0.1 到 0.2。5.5 只用当天降水做特征模型学到的是噪声现象随机森林的特征重要性排名第一的是当天的降水但模型预测过程线跟噪声一样NSE 只有 0.2。原因流域产流有汇流时间当天的降水至少要滞后几个小时到几天才会反映在河道流量里。直接用当天降水等于让模型去拟合一个时间错位的关系它当然学不到稳定的模式。解决给所有气象特征做滞后扩展把前 1 到 7 天的降水都作为特征。随机森林的 feature_importances_ 属性可以帮你验证如果最重要的特征是前 2 天的降水而不是当天降水说明模型已经学到了合理的汇流响应。这个信息反向还能帮你判断流域的汇流时间尺度算是建模附赠的水文知识。6. 验证不止看NSE分流量区间评估与多步滚动预测的实战技巧6.1 按流量区间分层评估把NSE拆开看整体 NSE 0.85 的模型可能在枯季表现完美、汛期完全失效但平均之后你根本看不出来。我的做法是把测试期的流量按从小到大排序用 33% 和 67% 分位数切成低流量、中流量、高流量三段分别计算 NSE 和 RMSE。def stratified_eval(obs, sim): low_q np.percentile(obs, 33) high_q np.percentile(obs, 67) segs { 低流量(P33): obs low_q, 中流量(P33-P67): (obs low_q) (obs high_q), 高流量(P67): obs high_q, } for name, mask in segs.items(): o obs[mask]; s sim[mask] n nse(o, s) rmse np.sqrt(np.mean((o - s) ** 2)) print(f{name}: NSE{n:.3f}, RMSE{rmse:.2f} m³/s, 样本数{len(o)})这个输出比单一 NSE 有用得多。如果低流量段 NSE 很高而高流量段 NSE 很低说明模型主要学了枯季模式需要回到第 5 章的峰值校准方案。如果三段 NSE 都不错但中流量段 RMSE 相对偏大可能是流域处于产流的临界转换状态特征里还缺少表征前期土壤湿度的变量。6.2 一步预测 vs 多步滚动预测验证模型真实可用性很多论文报的是单步预测的 NSE意味着模型每一步都用了前 7 天真实观测值来预测下一天。但调度场景要的是提前 3 到 7 天的滚动预报这时候输入窗口里全是自己预测出来的值误差会累积。所以拿到项目后第一件事就是先跑单步评估看模型学习能力再跑 7 日滚动评估看工程可用性。我通常的做法是设置一个滚动步长列表把第 1、3、7 天的滚动预测结果分别和实测画在同一张图上观察预测过程线什么时候开始明显偏离。如果第 3 天之后预测值就缩成一团均值说明模型没有学到足够的时间依赖结构这时候回去加 lookback 或换 LSTM 层数比在单步指标上反复调学习率有效得多。这套项目的源码包给了完整的模型类、训练脚本、滚动预测函数和数据示例文件。我自己的习惯是每次拿到新站点的径流数据后强制自己走一遍完整流程先拉过程线、做数据体检然后切分归一化三个模型各跑一遍最后用分层指标对比选型。不要跳过任何一步数据检查偷过的懒最后都会以更隐蔽的方式在指标里坑回来。希望帮到你。本文还有配套的精品资源点击获取