☰
基于Python的水位预测系统:从数据预处理到模型存档的完整实践
2026/10/12 4:43:06 网站建设 项目流程

简介:面向水文观测与深度学习交叉场景,这套基于Python的水位预测系统完整工程,适合具备机器学习基础、希望将BiRNN、GRU、LSTM等循环神经网络落地到时序预测任务中的研究生、数据科学初学者或水利信息化从业者,可帮助快速建立从数据处理到模型评估的完整认知。资源包共9个文件,约5.15MB,以6个h5模型权重文件为核心,配合Python脚本、Jupyter Notebook建模文档和CSV格式的原始水位数据,基本覆盖数据处理、模型训练到调用预测的完整链路,压缩包目录简洁,便于直接定位代码、模型与数据。目前已有446人学习下载。包内提供BiRNN、GRU、LSTM、SimpleRNN等不同结构的预训练模型,可直接加载测试,也可对比多种循环神经网络在同一次水位序列上的预测精度差异。读者可借此快速复现水位时序预测流程,结合notebook中的过程记录理解数据清洗、序列构造与训练参数设置,并将这套流程迁移到其他水文或相关时序预测项目中。

1. 基于 Python 的水位预测系统:一套能直接训练、存档与复用的实用源码包

防汛值班室最不需要的,是一套在测试集上屡创佳绩、一到梅雨季就失灵的水位预测模型。这个标题里的水位预测系统,核心不是某个酷炫算法,而是把「数据清洗 → 特征构造 → 模型训练 → 模型文件保存 → 重新加载预测」这一整条链路做成可复用的源码包。对正在做水文预报、城市内涝预警、闸站调度的人来说,这套东西的价值在于:拿到手不是看论文,而是能立刻把自己的站点数据灌进去,落地成能跑的业务脚本。本文按我一线的做法,把从数据处理到模型存档的完整路径拆开,重点讲参数怎么设、坑在哪,以及预测模型文件保存时最容易忽视的细节。

2. 水位数据预处理:缺测、重采样与滞后特征,先把训练样本造对

2.1 原始水位数据长什么样,先统一时间轴

我接触过的水位原始数据,差不多都是从遥测终端导出的 CSV 或直接从数据库拉的表,字段一般就三样:时间、水位、雨量。听着简单,但落地第一步就经常翻车——时间格式不统一、每分钟一条和每五分钟一条混着来、缺测用 -9999 表示、重复时间戳也没去重。如果不先把时间轴理顺,后面所有特征工程都是白做。

import pandas as pd import numpy as np # 读取原始数据,time 是时间列,level 是水位列,rain 是雨量列 df = pd.read_csv("raw_water_level.csv", parse_dates=["time"]) df = df.sort_values("time").reset_index(drop=True) # 统一到小时级:先去掉重复时间戳,再按小时取均值 df = df.drop_duplicates(subset="time", keep="last") df["time"] = df["time"].dt.floor("h") df = df.groupby("time", as_index=False).agg({ "level": "mean", "rain": "sum" })

代码逻辑很直白:先保证时间有序,再按小时聚合。水位取平均值,雨量取累计值。这里有个参数要按自己站点改:如果你的业务需要做 6 小时以内的短临预报,建议保留 15 分钟级,不要压到小时级;如果做 24 小时预报,小时级足够,太大反而会引入高频噪声。

2.2 缺测值处理:连续缺多久,决定用插值还是直接丢弃

水位遥测设备受通信、供电影响,缺测很常见。我常用的判断标准是:连续缺测小于 3 个点,用线性插值;超过 3 个点或出现在强降雨过程前后,插值会掩盖真实的涨水过程,宁可去掉这段。尤其是闸门启闭造成的水位跳变,插值会把一个台阶抹成斜坡,模型学不到真实规律。

# 先标记连续缺失的组,再按长度决定处理方式 df["is_missing"] = df["level"].isna().astype(int) df["missing_group"] = (df["is_missing"] != df["is_missing"].shift()).cumsum() # 连续缺失超过 3 条的整组删除,其余线性插值 for group_id, group in df.groupby("missing_group"): if group["is_missing"].sum() > 3: df.loc[group.index, "level"] = np.nan df["level"] = df["level"].interpolate(method="linear", limit_direction="both") df = df.dropna(subset=["level"]).reset_index(drop=True)

这段做两件事:先用分组统计找出长缺测段并置空,再用线性插值补短缺口。处理完后把仍为空的整行删掉。注意 limit_direction="both" 表示序列开头和结尾的缺测也会补,但开头缺太多时应当回头检查数据起点,而不是靠插值硬补。

2.3 特征工程:滞后值和滑动均值是水位模型的基本盘

水位预测本质上是用历史水位推未来水位。没有降雨资料或降雨资料质量差时,滞后特征就是最可靠的输入。我的默认配置是:预测未来 24 小时水位,至少给模型过去 24 小时的滞后水位,再加 3 小时、6 小时的滑动均值,让模型看到短时趋势。

# 构造滞后特征:lag_1 到 lag_24,按业务需求调节阶数 for lag in [1, 3, 6, 12, 24]: df[f"level_lag_{lag}"] = df["level"].shift(lag) # 滑动均值与标准差:捕捉上涨速度和波动程度 df["level_ma_3"] = df["level"].rolling(3).mean() df["level_ma_6"] = df["level"].rolling(6).mean() df["level_std_6"] = df["level"].rolling(6).std() # 把预测目标定义为“未来 24 小时后的水位” df["level_target"] = df["level"].shift(-24) # 删除尾部没有目标值的行 df = df.dropna().reset_index(drop=True)

这里最容易被忽略的是 shift(-24):它把当前时刻前 24 小时的水位作为目标,等价于让模型学习「已知过去 24 小时,预测未来 24 小时」。如果预测窗口改为 6 小时,就把 -24 改成 -6,同时滞后特征的阶数也可以缩小。我踩过最狠的坑是滞后阶数和预测窗口不匹配,模型实际上用了未来信息。

2.4 把预测目标从绝对水位改成变化量,模型会更稳

很多水位序列有明显趋势,尤其是汛期持续上涨。直接预测绝对水位,模型容易学到「把上一时刻水位照抄到未来」,预测曲线总是比实际慢半拍。我在训练里通常会生成一个增量目标,让模型预测未来水位相对当前时刻的变化值,预测时再把当前实测水位加回去。

# 增量目标:未来 24 小时水位 - 当前水位 df["level_delta_target"] = df["level"].shift(-24) - df["level"] # 建模输入特征是滞后水位、滑动统计量等,模型学习的是“变化量”

这样做还有一个附带好处:不同站点的基准水位差异很大,用变化量建模后,部分特征具有跨站点迁移的潜力。不过也要注意,蓄洪型水库水位受调度影响大,增量目标在某些场景下反而更难学,实际使用时可以两个目标都试跑一版,取验证集误差更小的。

3. 算法选型与训练:LSTM 和 XGBoost 怎么选,评估指标别只盯 R²

3.1 水位预测该用什么指标衡量:MAE、RMSE 和峰值误差

水位预测的业务价值不在于把每个点都预测得完美,而在于涨水过程、洪峰水位是否报得准。R² 在这样的场景下基本没有参考价值,水位序列自相关太强,即便预测曲线整体平移也能拿到很高的 R²。我一般同时看三个指标:MAE 衡量平均偏差,RMSE 放大峰值时段误差,还有一个自定义的峰值误差——取预测窗口内最大水位的预测值与真实值的差。

from sklearn.metrics import mean_absolute_error, mean_squared_error # y_true: 实测水位,y_pred: 预测水位 mae = mean_absolute_error(y_true, y_pred) rmse = mean_squared_error(y_true, y_pred, squared=False) # 峰值误差:窗口内最大水位之差 peak_err = np.max(y_pred) - np.max(y_true)

这三项指标在训练时都要打印出来。如果 MAE 低但峰值误差很大,说明模型在平水段拟合得好,涨水段失效,这种模型对防汛值班没有意义。

3.2 模型选型:LSTM、XGBoost 还是从线性回归开始

模型选择我倾向保守路线。先跑一个线性回归作为基线,如果基线就能做到 MAE 在 20 厘米以内,就不必上复杂模型。XGBoost 处理表格特征强,调参门槛低,且特征重要性可解释,绝大多数水位站点用它就够了。LSTM 的优势在于能直接吃多步时间窗口,不用手动构造滞后特征,但数据量少、超参数敏感,训练起来更像玄学,效果未必比 XGBoost 好。

模型优点缺点适用场景
线性回归稳定、可解释、跑得快抓不住非线性涨水数据少、做基线
XGBoost表格特征强、调参友好需手动构造滞后特征大多数站点首选
LSTM自动学时序依赖需大量数据、易过拟合长序列、数据充足

如果你想快速验证整套源码是否跑得通,我建议先用 XGBoost 出一版结果,再尝试 LSTM。不要一上来就调 LSTM 的神经元数量和层数,那是在没有基线的情况下给自己挖坑。

3.3 训练集切分:时间顺序切分,绝不随机切分

水位预测是典型的时间序列问题,随机切分训练集和验证集是致命错误。随机切会让模型在训练时看到「未来」的片段,验证集失真。标准的做法是按时间排序后,用前 80% 训练、后 20% 验证,验证集模拟真实的「用历史预测未来」。

# 建一个完整的建模数据表,注意必须先按时间排好序 features = [c for c in df.columns if c.startswith(("level_lag", "level_ma", "level_std"))] X = df[features].values.astype("float32") y = df["level_delta_target"].values.astype("float32") # 按时间顺序切分:前 80% 训练,后 20% 验证 split_idx = int(len(X) * 0.8) X_train, X_valid = X[:split_idx], X[split_idx:] y_train, y_valid = y[:split_idx], y[split_idx:]

3.4 训练 XGBoost 并保存模型本体

XGBoost 的训练核心参数我一般固定这几项:n_estimators 先给 500,early_stopping 设 50,max_depth 在 4 到 6 之间调,learning_rate 用 0.05。水位数据量通常不大,加大 depth 容易过拟合到历史洪水的个别样本上。

import xgboost as xgb model = xgb.XGBRegressor( n_estimators=500, max_depth=5, learning_rate=0.05, subsample=0.8, colsample_bytree=0.8, random_state=42 ) model.fit( X_train, y_train, eval_set=[(X_train, y_train), (X_valid, y_valid)], verbose=False ) # 训练完成后立即保存,避免后续重复训练 model.save_model("outputs/water_level_xgb.json")

save_model 是 XGBoost 官方推荐的保存方式,直接用 joblib.dump 存整个对象在版本升级时容易出问题。这里只存了模型本体,归一化对象和特征清单的保存是第四章重点,那一环不跟上,模型文件换个环境就废了。

3.5 训练 LSTM:输入要三维,归一化要一次到位

LSTM 需要的输入是三雏:样本数、时间步数、特征数。在 XGBoost 里我们喂的是「一行样本包含多个滞后特征」,在 LSTM 里可以把窗口内的多个时刻组织成一个二维矩阵,这样模型能看到特征随时间的变化过程。我常用的做法是设置 lookback=12,也就是用过去 12 个小时的数据预测未来。

from tensorflow.keras.models import Sequential from tensorflow.keras.layers import LSTM, Dense, Dropout from sklearn.preprocessing import MinMaxScaler # 归一化训练特征和目标,注意两个 scaler 都要保存 scaler_x = MinMaxScaler() scaler_y = MinMaxScaler() X_scaled = scaler_x.fit_transform(X) y_scaled = scaler_y.fit_transform(y.reshape(-1, 1)).ravel() # 把样本重排成 LSTM 需要的 [样本数, 时间步, 特征数] lookback = 12 X_lstm = [] y_lstm = [] for i in range(lookback, len(X_scaled)): X_lstm.append(X_scaled[i - lookback:i]) y_lstm.append(y_scaled[i]) X_lstm = np.array(X_lstm, dtype="float32") y_lstm = np.array(y_lstm, dtype="float32")

这段循环把 X_scaled 切成长度为 12 的滑动窗口。第 i 个样本用第 i-12 到 i-1 的特征序列,预测第 i 时刻的目标。参数 lookback 是 LSTM 里最值得调的:太小模型看不到趋势,太大训练成本高且容易记住历史噪声。我个人经验,12 到 24 之间是不错的起点。

model_lstm = Sequential([ LSTM(64, return_sequences=True, input_shape=(lookback, X_scaled.shape[1])), Dropout(0.2), LSTM(32, return_sequences=False), Dropout(0.2), Dense(16, activation="relu"), Dense(1) ]) model_lstm.compile(optimizer="adam", loss="mse", metrics=["mae"]) history = model_lstm.fit( X_lstm, y_lstm, validation_split=0.1, epochs=100, batch_size=32, verbose=0 ) model_lstm.save("outputs/water_level_lstm.keras")

epochs 设 100 但建议配合早停。我一般不手动把 epochs 调大跑满,而是训练时观察验证集 MAE,连续 10 个 epoch 不降就停,防止把噪声也学进去。保存用 .keras 新格式,比旧版 .h5 更兼容新版框架。

4. 预测模型文件落地:保存、加载与调用闭环,别让存档毁在最后一步

4.1 模型文件该存什么:模型本体、归一化对象、特征清单缺一不可

标题里的「预测模型文件」是最容易让新人误解的部分。很多人以为把模型保存下来就结束了,结果换台机器加载时发现输入维度对不上,或者预测结果是一个恒定值。真正的模型文件是一套目录,至少包含三样东西:模型权重文件、特征归一化对象、特征和目标的元信息。归一化对象决定了新数据进来用什么方式变换,特征清单决定了预测时按什么顺序取列。

import joblib import json import os os.makedirs("outputs", exist_ok=True) # 保存 XGBoost 模型本体 model.save_model("outputs/water_level_xgb.json") # 保存两个归一化对象:x 和 y 分别存,预测时才能还原真实水位 joblib.dump(scaler_x, "outputs/scaler_x.pkl") joblib.dump(scaler_y, "outputs/scaler_y.pkl") # 保存特征清单与业务参数,加载时严格按这个顺序取特征 meta = { "features": features, "target": "level_delta_target", "future_hours": 24, "freq": "h" } with open("outputs/model_meta.json", "w", encoding="utf-8") as f: json.dump(meta, f, ensure_ascii=False, indent=2)

这段代码里最关键的细节是 scaler_y。水位预测的目标是「未来水位变化量」,训练时做了归一化,预测得到的结果是归一化区间内的数值,必须用同一个 scaler_y 做 inverse_transform 才能还原成实际水位。我见过不止一次把这一步漏掉,预测输出全部集中在 0 到 1 之间,完全无法使用。

4.2 模型文件的加载与调用:写一个统一的预测函数

模型存档的意义在于复用。我习惯把加载和预测封装进一个函数,新数据进来,先走同样的特征处理逻辑,再加载模型输出预测值。这样在 Flask 服务、定时任务或命令行脚本里都能一致调用。

import numpy as np import xgboost as xgb def load_model_artifacts(model_dir="outputs"): model = xgb.XGBRegressor() model.load_model(f"{model_dir}/water_level_xgb.json") scaler_x = joblib.load(f"{model_dir}/scaler_x.pkl") scaler_y = joblib.load(f"{model_dir}/scaler_y.pkl") with open(f"{model_dir}/model_meta.json", "r", encoding="utf-8") as f: meta = json.load(f) return model, scaler_x, scaler_y, meta def predict_water_level(model, scaler_x, scaler_y, meta, df_latest): # 按训练时的逻辑构造特征 X_new = df_latest[meta["features"]].values.astype("float32").reshape(1, -1) X_scaled = scaler_x.transform(X_new) # XGBoost 预测 + 逆变换 + 加上当前水位还原绝对水位 delta_scaled = model.predict(X_scaled)[0] delta = scaler_y.inverse_transform([[delta_scaled]])[0][0] current_level = df_latest["level"].values[-1] return current_level + delta

参数说明:meta["features"] 里的列顺序必须与训练时完全一致。很多人第二次写预测脚本时按自己的习惯重新排了特征列,模型直接预测出完全错误的值,这就是黑匣子问题的最常见来源。

4.3 模型文件格式对比:.json、.pkl 与 .keras 各有用处

文件类型保存内容加载方式推荐场景
xgb.jsonXGBoost 树结构load_modelXGBoost 官方格式,跨版本兼容
scaler_x.pkl特征归一化参数joblib.load必须与模型同目录保存
scaler_y.pkl目标归一化参数joblib.load预测时还原真实值
model_meta.json特征列、预测窗口json.load自解释文件,防止调用方误用
lstm.kerasLSTM 权重与结构keras.models.load_modelKeras 新格式

我倾向于把所有文件放在 outputs 目录下,并且把这个目录整体当作「模型文件」交付。单一文件看似简单,实际上是最容易埋雷的交付方式。

5. 水位预测系统的常见问题与排查:数据泄露、迟滞预测与静默失效

5.1 训练集随机切分导致的数据泄露

现象:训练集和验证集上的误差都很漂亮,MAE 能压到 5 厘米以内,结果一上真实业务就完全不对,预测曲线明显「记得」未来走势。

原因:随机切分把时间上相邻的样本分到了训练集和验证集两侧,模型在训练时已经见过验证集时间段的信息,属于典型的数据泄露。

解决:所有切分严格按时间顺序。训练集永远是较早的 80%,验证集是较晚的 20%。如果数据跨越多个汛期,建议按年份切分,用前两年的完整过程训练,用最近一年的数据验证,这样更接近真实部署场景。

5.2 预测结果等于「上一个时刻水位的平移」

现象:预测曲线比实测曲线慢半拍,涨水时预测偏低,退水时预测偏高,曲线形态像是在延迟几个小时后照抄。

原因:目标直接设为未来绝对水位,模型发现最简单的路径就是复制当前水位,滞后特征 lag_1 权重被拉满,其他特征形同虚设。

解决:把目标改为未来水位与当前水位的差值,也就是增量目标。训练脚本里 pred = current_level + delta,从结构上迫使模型学习涨水速率而不是直接复制。另一个辅助手段是观察 XGBoost 的特征重要性,如果 lag_1 占比超过一半,这个症状就很明显。

5.3 换了机器后模型文件加载失败或预测结果恒定

现象:同一份模型文件在自己电脑上预测正常,拷到同事电脑或服务器上后,要么报错,要么预测输出全部是同一个数字。

原因:大概率是归一化对象没有一起拷贝。模型文件记录了归一化后的数值关系,但新机器上如果没有 scaler_x.pkl,新数据会用默认参数或错误参数做变换,输入分布完全偏离训练时的范围。

解决:交付时把 outputs 整个目录打包,加载代码强制检查 scaler_x 和 scaler_y 是否存在,不存在就抛异常,不要静默运行。预测结果恒定的另一个原因是目标增量已经归一化但没做 inverse_transform,检查预测脚本里是否有这一步。

5.4 暴雨时段预测误差急剧放大

现象:平水期表现良好,一到强降雨过程预测误差直接到 1 米以上,峰值水位报不准。

原因:训练集中高水位样本占比太少。水位时序数据天然是不平衡的,平水期样本占绝大多数,模型把注意力都花在了拟合常见区间,极端样本被当成噪声忽略。

解决:训练时给高水位样本加权。最简单的做法是按水位分位数分层抽样,把历史高水位区间的样本复制或加权。另一个兜底方案是设定预警阈值:当实测水位超过该站点警戒水位的 80% 时,在预测结果旁标注「模型可能低估」,提醒值班人员参考实测涨率。

5.5 站点 A 训练好的模型直接用于站点 B,预测完全失效

现象:把某水文站的模型文件复制到另一个站点,不做任何处理直接预测,结果偏得离谱。

原因:不同站点的水位基准面、河道断面、闸门调度规则都不一样,绝对水位的概率分布差异很大,训练集上的统计规律无法迁移。

解决:优先为每个站点单独训练模型。如果站点数量多、数据量不足,可以采用相对水位做训练和预测——把水位减去该站历史均值再除以标准差,模型学习的是标准化后的规律,部分地区能迁移。但这个方法不是万能的,闸门调度模式的差异没法靠归一化消除,迁移前务必在目标站点做一段时间的验证。

6. 持续验证与滚动更新:让水位预测模型活在预警流程里,而不是躺在磁盘里

模型文件保存好不是终点,真正麻烦的是后续验证。我现在的做法是维护一个滚动回测脚本,每来一批新数据就自动跑一次,把模型预测值和实测值对比,误差超限时触发重训提醒。

# 滚动回测:模拟真实部署,每 24 小时用现有模型预测未来 24 小时 history_data = df.iloc[:split_idx].copy() errors = [] for i in range(split_idx, len(df) - 24, 24): # 用截至当前时刻的数据构造特征 latest_df = history_data.copy() X_new = latest_df[meta["features"]].values[-1:].reshape(1, -1) X_scaled = scaler_x.transform(X_new) delta = scaler_y.inverse_transform(model.predict(X_scaled).reshape(-1, 1))[0][0] pred = latest_df["level"].values[-1] + delta # 与 24 小时后的实测值对比 actual = df.iloc[i + 24]["level"] errors.append(abs(pred - actual)) # 把当前时刻真实数据追加进历史,继续下一轮 history_data = pd.concat([history_data, df.iloc[[i]]])

这段回测脚本的价值在于:它模拟的是「站在当前时刻,只用过去数据预测未来」,而不是一次性把全量数据喂进模型。当误差的中位数开始持续走高,就该检查是不是河道断面变了、上游新增了水利工程,或者训练数据的分布已经老化。水位预测系统最怕的不是模型不够聪明,而是没人发现它已经悄悄失效。希望这篇笔记能帮你把预测模型文件从「训练完就吃灰」变成真正能持续服役的预警工具,也希望你在部署时少踩我踩过的那些坑。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询