简介:面向计算机相关专业课程设计与期末大作业场景,这份基于深度学习的溶解氧时间序列预测项目,提供完整Python源码与配套水质数据,覆盖数据预处理、模型构建、结果对比与异常检测等环节。压缩包共16个文件,以11个.py脚本和5个.csv数据文件为主,整体仅931KB;其中包含LSTM、EMD-LSTM、EEMD-LSTM等主流时序预测模型实现,以及DBSCAN、LOF、OneClassSVM、孤立森林等异常检测脚本,方便学习不同思路。数据文件含多组水质记录与修正版本,配合预处理脚本即可直接开展实验,项目经过严格调试,下载解压即可运行,免去环境与数据准备烦恼。目前已有227人学习下载,目录结构清晰,适合需要完整可运行项目实战的学生快速上手,也适合作为课程设计或期末大作业的参考方案。
1. 凌晨的低谷预警,才是溶解氧预测的真正价值
凌晨两点的水体溶氧量,是整个养殖周期里最让人揪心的数字。增氧机晚开十分钟,水面可能已经开始飘浮头鱼;等在线仪表报警再跑过去,损失早就发生了。所以我一直认为,溶解氧时间序列预测这件事,价值不在“报当前值”,而在“提前两小时告诉你低谷几点来、值是多少”。用 Python 做基于深度学习的溶解氧时间序列预测,就是把历史传感器记录变成提前量,让预警从“事后跳变”变成“事前曲线”。这篇笔记按这类项目源码的真实落地路径来讲:数据清洗、滑窗建样本、LSTM 建模、训练验证再到踩坑排错,适合手里有水质历史数据、想做预警系统的人,也适合拿它练手完整时序预测流程的 Python 开发者。
2. 溶解氧数据为什么吃这套:特征、选型与项目文件
2.1 溶解氧数据的三个特征:强周期、强噪声、强滞后
溶解氧序列和股票价格最大的不同,是它有规律的昼夜周期。白天藻类光合作用产氧,DO 曲线缓慢爬升,午后到达峰值;夜间呼吸作用耗氧,凌晨跌到低谷。这个周期虽然稳定,但会被水温、气压、降雨、增氧机启停打乱,所以它“有周期但不纯”,属于典型的强周期、强噪声、强滞后信号。
这三个特征直接决定选型方向。传统 ARIMA 的第一步是差分让序列平稳,一差分就把昼夜周期信息破坏了大半,拟合出来的曲线总是慢半拍。XGBoost 这类树模型本身没有时序记忆,必须手工构造滞后特征才能捕捉 24 小时前的形态,特征工程做起来很重。而 LSTM 这类循环神经网络可以在原始尺度上直接学习“过去 24 小时的整体形态”,把“记住多久”变成可训练的门控参数,跟溶解氧这种信号的匹配度天然更高。
2.2 为什么是 LSTM,而不是更花哨的结构
你搜“lstm时间序列预测python”,排在前面的多半是股票和负载预测,水质相关的不少是拿公开数据集做的教学 Demo,真正能用的要自己做大量数据清理。这也引出一个关键判断:LSTM 不是越复杂越好。如果手里只有几十条数据,ARIMA 和线性回归反而更稳;LSTM 的收益要体现在至少上千条、最好能覆盖多个昼夜周期的记录上,而且需要干净的输入。
Transformer 这两年很火,但它对序列长度和算力的要求比 LSTM 高一个量级,在单变量、短序列、CPU 也能跑的溶解氧预测场景里性价比不高。常见做法是先用双层 LSTM 做基线,跑通之后再考虑加注意力机制或者换更重的结构。深度学习在这个场景里的定位不是炫技,而是用一个靠得住的 baseline 把预警系统先跑起来。
2.3 拿到源码包先看哪几个文件
这类“项目源码+全部数据”的压缩包,文件边界通常很清晰。我一般会按这个顺序读:train.py 是训练入口,先把它从头到尾读一遍,搞清楚数据从哪来、模型怎么建、结果存到哪;data_process.py 是数据预处理,决定模型的输入长什么样;model.py 是网络定义,看层数和参数;predict.py 是推理脚本,看它怎么加载权重、怎么输出预测值;data 目录下是原始 CSV,直接决定数据清洗的工作量。
有一个经验:不要一上来就打开 model.py 研究网络结构。先跑通 train.py,再回头看模型,这样即使对代码不熟,也能在十分钟内建立“数据长什么样、模型怎么学、结果怎么出”的完整印象,避免陷入某个函数的细节里出不来。
2.4 三分钟把环境跑起来
这份源码按 Python 3.8+ 设计,依赖不复杂。我建议建一个干净的环境,按顺序安装:
pip install numpy pandas matplotlib scikit-learn pip install tensorflow如果你的机器有 NVIDIA 显卡,装 tensorflow 的 GPU 版本能明显缩短训练时间;没有显卡也不用担心,这种单变量序列预测的数据量不大,CPU 跑几百轮通常十几分钟到半小时就结束。跑通是最重要的,不要为环境配置卡太久。
3. 把原始传感器记录变成预测样本:清洗、滑窗与切分
3.1 先处理缺失与异常,再谈预测
传感器数据拿到手,第一件事永远不是建模,而是看数据有多脏。溶解氧探头泡在水里,结垢、断电、清洗维护都会产生缺失值和异常跳变。我见过一份源码里原始 CSV 的时间列有重复索引,直接用 pandas 读进来后按行滑窗,结果一个窗口里混了两天的数据,模型训练出来全是噪声。
我的常规处理顺序是:先按固定频率重采样,统一时间步长;再对缺失值插值;最后对物理上不可能的值做裁剪。代码如下:
import pandas as pd df = pd.read_csv("data/do_raw.csv", parse_dates=["time"]) df = df.set_index("time") # 按小时对齐,缺失的整点会被补成 NaN df = df.resample("1h").mean() # 线性插值,前后都补 df["do"] = df["do"].interpolate(method="linear", limit_direction="both") # 溶解氧物理范围约 0~20 mg/L,超出直接裁剪 df["do"] = df["do"].clip(lower=0, upper=20) # 最后确认没有残留 NaN assert df["do"].isna().sum() == 0, "还有缺失值,检查重采样范围"重采样的作用是把不规则的传感器上报频率统一成等间隔序列,LSTM 对输入的时间步长很敏感,间隔不匀会让它以为相邻两步的时间距离相同。插值用线性方法就够,溶解氧变化缓慢,线性插值不会引入明显误差。注意limit_direction="both"这个参数,它保证序列开头和结尾的缺失也能被补上,不加这个参数头部会残留 NaN。
3.2 用滑窗把时间序列切成监督学习样本
LSTM 不能直接吃时间序列,它吃的是“样本对”:一个窗口的过去值作为 X,窗口之后的值作为 y。这一步在时序预测里叫滑窗,思路和“python量化交易策略代码”里的特征截取是同一套逻辑,只是目标从收益率换成了水质指标。
import numpy as np def sliding_window(data, window_size=24, pred_len=1): X, y = [], [] for i in range(len(data) - window_size - pred_len + 1): X.append(data[i : i + window_size]) y.append(data[i + window_size + pred_len - 1]) return np.array(X), np.array(y) do_values = df["do"].values.reshape(-1, 1) X, y = sliding_window(do_values, window_size=24, pred_len=1) print(X.shape, y.shape)window_size=24表示用过去 24 小时预测未来,这个值对应“一天”的周期,是溶解氧预测里最常用的起点。pred_len=1表示单步预测,即预测下一个整点的 DO 值。如果你想要更长的提前量,比如预测未来 3 小时,不能简单把pred_len设成 3 然后只取i + window_size + pred_len - 1这一个点——那只是“3 小时后的那一个值”,而不是“未来 3 小时的曲线”。真正多步预测应该把预测目标改成未来 3 个点的序列,或者在做推理时把模型输出再塞回输入做迭代预测,这是新手最容易搞混的地方。
提示:这个循环写法数据量小没问题,几万条以内随便跑。如果数据到了几十万条,建议用
numpy的 stride_tricks 或tf.data.Dataset.window做向量化滑窗,否则建样本就要等半天。
3.3 归一化与时间顺序切分:两个边界坑
归一化我用 MinMaxScaler,把溶解氧压到 0~1 之间,LSTM 对这种输入收敛更快。但切分顺序上有个关键约束:时间序列的数据集不能随机 shuffle,必须按时间先后切分,否则训练集里混进了未来信息,验证集指标虚高得一塌糊涂。
from sklearn.preprocessing import MinMaxScaler scaler = MinMaxScaler(feature_range=(0, 1)) scaled = scaler.fit_transform(do_values) # 按时间顺序切分,训练:验证:测试 = 7:2:1 train_end = int(len(scaled) * 0.7) val_end = int(len(scaled) * 0.9) train = scaled[:train_end] val = scaled[train_end:val_end] test = scaled[val_end:] X_train, y_train = sliding_window(train, 24, 1) X_val, y_val = sliding_window(val, 24, 1) X_test, y_test = sliding_window(test, 24, 1)注意scaler.fit_transform只作用在训练段上,val 和 test 直接用训练段学到的 min/max 做transform。这里有个常见错误:把全部数据一起 fit 再切分,等于让验证集“偷看”了训练集的数据分布。虽然分布信息不像时序泄漏那么致命,但在模型上线后,新数据的取值范围很可能超出历史 min/max,导致预测值被钉在边界上,这个坑放到第 6 章细讲。
4. 搭一个能记住昨天水质的 LSTM:网络结构、参数与 PyTorch 写法
4.1 model.py 里真正核心的几行
处理完数据,下一步就是把网络搭起来。大多数这类源码的 model.py 核心是一个双层 LSTM 加全连接输出,用 Keras 写出来大致是这个样子:
from tensorflow.keras.models import Sequential from tensorflow.keras.layers import LSTM, Dense, Dropout model = Sequential() model.add(LSTM(units=64, return_sequences=True, input_shape=(24, 1))) model.add(Dropout(0.2)) model.add(LSTM(units=32, return_sequences=False)) model.add(Dropout(0.2)) model.add(Dense(units=1)) model.compile(optimizer="adam", loss="mse", metrics=["mae"]) model.summary()第一层return_sequences=True很关键:它让这一层输出每个时间步的隐状态,而不是只输出最后一个,这样才能把完整的时间步序列传给第二层 LSTM。第二层return_sequences=False,只需要最后一个时间步的隐状态,用来接全连接层输出预测值。input_shape=(24, 1)里的 24 就是滑窗长度,1 是特征数,如果你后续加入温度、气压等变量,这个 1 要改成对应的特征数量。
units是隐状态维度,64 和 32 是比较稳的起点。隐状态维度决定了 LSTM“记住多久”的容量:太小记不住昼夜形态,太大在数据量不足时容易过拟合。损失函数用mse对回归任务最直接,训练过程中同时监控mae方便人眼解读误差量级。
4.2 超参数怎么定:一份能落地的参考表
很多新手拿到源码第一件事是改网络结构,但溶解氧预测的效果瓶颈通常不在结构,而在滑窗长度、批量大小和学习率这几个参数上。我整理了一份常用的起始配置,按这份配置跑出来的结果一般不会太离谱:
| 参数 | 建议值 | 说明 |
|---|---|---|
| window_size | 24 或 48 | 24 对应一天周期,48 包含昨天和今天可对照 |
| units | 64 / 32 | 第一层 64,第二层 32,数据量大可翻倍 |
| dropout | 0.2 | 防止过拟合,数据量小可以提到 0.3 |
| batch_size | 64 | 显存不够就降到 32,影响收敛稳定性 |
| learning_rate | 1e-3 | Adam 默认值,loss 震荡就降到 5e-4 |
| epochs | 200 | 配合早停使用,不用手动纠结轮数 |
一个务实的建议:先跑一次小规模实验,比如 units 减半、epochs 设 50,确认 loss 在下降、预测曲线形态正确,再加大规模。直接上全量参数训练,等了半小时发现数据预处理有 bug,才是最亏的。
4.3 切换到 PyTorch 怎么写
源码里如果用 PyTorch,网络定义思路和 Keras 一样,只是 API 风格不同。核心是一个继承nn.Module的类,在forward里定义数据流向:
import torch.nn as nn class LSTMNet(nn.Module): def __init__(self, input_size=1, hidden_size=64, num_layers=2, output_size=1): super().__init__() self.lstm = nn.LSTM( input_size, hidden_size, num_layers, batch_first=True, dropout=0.2 ) self.fc = nn.Linear(hidden_size, output_size) def forward(self, x): out, _ = self.lstm(x) # out shape: (batch, seq_len, hidden_size) out = self.fc(out[:, -1, :]) # 只取最后一个时间步 return out和 Keras 版的差异有两点:一是batch_first=True让输入形状变成 (batch, seq_len, input_size),和 Keras 默认一致,省得调换维度;二是 PyTorch 需要用out[:, -1, :]手动取出最后一个时间步的隐状态,再接全连接层。训练时记得加optimizer.zero_grad()清空梯度,推理时用torch.no_grad()包住前向计算,这些细节虽然小,但新手第一次跑 PyTorch 基本都会卡在这。
5. 训练与验证:回调、指标和第一张要存的图
5.1 三个回调是训练不翻车的第一道保险
训练循环本身不复杂,真正让训练“不玄学”的是三个回调。早停防止过拟合,学习率衰减让模型在后期走稳,模型检查点保住最好的结果。这三个一起用,基本就不用守在屏幕前盯 loss 曲线了。
from tensorflow.keras.callbacks import EarlyStopping, ReduceLROnPlateau, ModelCheckpoint callbacks = [ EarlyStopping(monitor="val_loss", patience=15, restore_best_weights=True), ReduceLROnPlateau(monitor="val_loss", factor=0.5, patience=5, min_lr=1e-6), ModelCheckpoint("best_model.h5", monitor="val_loss", save_best_only=True) ] history = model.fit( X_train, y_train, validation_data=(X_val, y_val), epochs=200, batch_size=64, callbacks=callbacks, verbose=1 )patience=15表示连续 15 个 epoch 验证集 loss 没有下降就停止训练,这比固定 epochs 靠谱得多,不需要你提前猜要训练多少轮。restore_best_weights=True是后悔药:即使后面过拟合了,也会把权重回滚到验证集表现最好的那个 epoch,这个参数我每次都开。ReduceLROnPlateau在验证 loss 连续 5 轮不降时把学习率减半,让训练在接近最优点时走小步。三个回调共同作用,绝大多数情况下你只需要等训练结束,然后去看模型文件。
5.2 评估只看 RMSE 远远不够:把 MAPE 和滞后一起看
训练完的下一步是评估。很多源码只打印一个 RMSE,但溶解氧预测只盯 RMSE 会漏掉两个关键问题:一是大值拉高整体误差,掩盖低谷预测不准的事实;二是单纯看误差数值完全看不出预测曲线是不是“慢半拍”。
from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score import numpy as np y_pred = model.predict(X_test) # 反归一化回真实量纲再看误差 y_test_actual = scaler.inverse_transform(y_test.reshape(-1, 1)) y_pred_actual = scaler.inverse_transform(y_pred.reshape(-1, 1)) def mape(y_true, y_pred, eps=1e-6): return np.mean(np.abs((y_true - y_pred) / (y_true + eps))) * 100 rmse = np.sqrt(mean_squared_error(y_test_actual, y_pred_actual)) mae = mean_absolute_error(y_test_actual, y_pred_actual) r2 = r2_score(y_test_actual, y_pred_actual) print(f"RMSE={rmse:.3f}, MAE={mae:.3f}, R2={r2:.3f}, MAPE={mape(y_test_actual, y_pred_actual):.2f}%")RMSE 对离群值敏感,适合用来发现“有没有极端预测错误”;MAE 反映平均偏差,好理解;MAPE 在溶解氧这种量程有限(0~20 mg/L)的指标上很直观,但接近 0 的点会把百分比放大到离谱,所以分母加了个极小值防除零。这四个指标一起看,才能判断模型到底是“整体偏大”还是“个别点崩掉”。另一个直观方法是算预测值和前一个实测值的相关系数,如果两者的相关度高得异常,说明模型在“抄近道”——本质上是把昨天的值平移了一下,没有真正学到周期规律。
5.3 第一张应该存下来的图
跑通训练后,我建议立刻画一张测试集的预测对比图,这张图比任何指标都更能说明问题。图画出来,预测能力基本一目了然。
import matplotlib.pyplot as plt plt.figure(figsize=(12, 4)) plt.plot(y_test_actual, label="actual", linewidth=2) plt.plot(y_pred_actual, label="pred", linewidth=1.5, linestyle="--") plt.legend(loc="upper right") plt.ylabel("DO (mg/L)") plt.tight_layout() plt.savefig("test_result.png", dpi=150)看这张图时重点看三个位置:凌晨低谷有没有跟住、峰值是不是被削平、曲线的整体相位有没有右移。低谷是增氧机启动的关键点,如果预测值和实测峰谷对不上,误差再小也没用。建议再画一张只包含凌晨 0 到 6 点的放大图,专门检查低谷预测的精度,这才是一线真正关心的时段。
6. 避坑:5 个让溶解氧预测翻车的典型错误
6.1 预测曲线整体右移一个采样点,看起来拟合得很好但没法用
现象:测试集上的预测曲线和实测曲线形态几乎一样,RMSE 很低,但仔细看发现预测值整体比实测值晚了一个采样点,像个“慢半拍”的跟屁虫。凌晨低谷来的时候,模型还没反应过来。
原因:单步预测任务存在一个“捷径”——直接复制上一个观测值就能拿到很低的 loss。模型学到的最优策略不是理解昼夜周期,而是“上个小时是多少,这个小时就报多少”。如果评估时只看 RMSE,这个滞后完全看不出来。
解决:改用多步预测目标,一次预测未来 1 小时、3 小时、6 小时三个点,逼着模型去学趋势而不是抄近路。同时把指标从 RMSE 换成对相位敏感的评估,或者直接用低谷时刻的预报误差来验收。
6.2 验证集指标好看,一上线就翻车
现象:本地跑源码时验证集 R2 能做到 0.9 以上,部署到现场预测新数据,误差立刻变大,曲线全飘。
原因:这是时间泄漏的典型症状。训练前做了随机 shuffle,或者归一化时把全量数据一起 fit,让验证集“偷看”了训练集的信息。更隐蔽的情况是数据里有重复时间段,切分后训练集和验证集包含同一批时间点的不同特征。
解决:严格按时间顺序切分,验证集取最后 20%,测试集取最后 10%,切分前先检查时间索引有没有重叠。归一化的 fit 只作用在训练段上,测试段只做 transform。这个坑我在第三次做时序预测时才彻底记住,建议当成固定流程写进代码注释里,每次跑都检查一遍。
6.3 预测值长期钉在上下边界,新数据的检测值根本拉不动曲线
现象:模型上线一个月后,预测值频繁出现 0 或 1.0,反归一化后变成 0 mg/L 或 20 mg/L 这种极限值,完全失真。
原因:用历史全量数据拟合 MinMaxScaler 时,min 和 max 是过去某段时间的极值。新数据一旦超出这个范围,归一化后的值就超过 1,而模型输出的激活函数把它压回边界。溶解氧传感器经过清洗校准后,基线和极值都可能漂移,历史归一化的范围会失效。
解决:归一化的上下限不用数据中的极端值,而是用物理常识设定。溶解氧的量程上限就是 20 mg/L,下限 0,直接设成MinMaxScaler(feature_range=(0, 1), clip=True)或者手动指定范围为 [0, 20]。另外定期用最近 90 天的数据重新拟合 scaler,别让归一化参数永久冻结。
6.4 训练 loss 直接变成 NaN,模型几分钟就崩
现象:训练跑到第 2 或第 3 个 epoch,loss 突然变成 nan,之后一直无法恢复。
原因:最常见的有两个:一是原始数据里混入了 NaN 或超大异常值,重采样插值时没处理干净;二是学习率对当前数据分布太大,梯度更新越过最优点后发散。
解决:先跑df.isna().sum()和df.describe()检查数据分布,把异常值 clip 到物理范围。如果数据干净,就把初始学习率从 1e-3 降到 1e-4,或者在优化器上打开梯度裁剪,Keras 里可以传clipnorm=1.0给 Adam 优化器,PyTorch 用torch.nn.utils.clip_grad_norm_。这条经验同样套用在其他时间序列预测场景,不只是溶解氧。
6.5 多站点数据混在一起训练,模型被“平均”到谁的曲线都不像
现象:把 A 站和 B 站的水质记录拼在一起训练,单站点验证时误差都不小,模型像在两边之间取折中。
原因:不同水体的溶解氧基线差很多,A 站长年平均 7 mg/L,B 站可能常年只有 4 mg/L。混在一起训练后,模型为了兼顾两边的 loss,学到的是一个中间态的分布,对谁都不精确。
解决:最稳妥的是每个站点单独建模,反正单变量序列预测的数据量不大,多训练几个模型成本也可控。如果一定要共用模型,先按站点做标准化,把各站数据都转成相对自身基线的偏差,或者加一个站点 ID 类别特征,让 LSTM 知道当前输入属于哪个水体。但说实话,站点独立建模的收益通常更直接。
7. 从预测曲线到预警动作:验证与下一步部署
7.1 上线前先跑一段“影子预测”
模型在测试集上表现好,不等于现场可靠。我习惯在真正接预警系统前,先做两周影子预测:模型照常输出,但结果只记录不下发。每天把预测低谷和实测低谷对比,计算“低谷命中误差”。如果连续一周低谷预测误差都在正负半小时以内,再考虑接入自动控制,这个方法成本低,能挡住大部分模型不靠谱的风险。
7.2 下一步投入产出比最高的方向:加外部变量
溶解氧不只受自身历史影响,它和水温、气压、pH 高度联动。把这些变量一起送进模型,输入特征从 1 变成多路,LSTM 的input_shape相应改成(window_size, n_features),代码改动很小,但对低谷预测的收益通常很明显。其次是定时重训:用最近 90 天数据每两周重训一次,避免模型随水质季节性变化而老化。这两个方向都比换更复杂的网络结构划算。
7.3 一个我养成的验收习惯
我现在跑完任何时序预测模型,第一件事不是看测试集分数,而是把预测曲线和原始曲线并排打开,盯着看十分钟,确认它抓到了凌晨那个低谷、峰值没有被削成平顶,再决定要不要部署。这个习惯救过我很多次,指标可能是会骗人的,曲线不会。希望帮到你。
本文还有配套的精品资源,点击获取