简介面向计算机相关专业课程设计与期末大作业场景这份基于深度学习的溶解氧时间序列预测项目提供完整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(methodlinear, limit_directionboth) # 溶解氧物理范围约 0~20 mg/L超出直接裁剪 df[do] df[do].clip(lower0, upper20) # 最后确认没有残留 NaN assert df[do].isna().sum() 0, 还有缺失值检查重采样范围重采样的作用是把不规则的传感器上报频率统一成等间隔序列LSTM 对输入的时间步长很敏感间隔不匀会让它以为相邻两步的时间距离相同。插值用线性方法就够溶解氧变化缓慢线性插值不会引入明显误差。注意limit_directionboth这个参数它保证序列开头和结尾的缺失也能被补上不加这个参数头部会残留 NaN。3.2 用滑窗把时间序列切成监督学习样本LSTM 不能直接吃时间序列它吃的是“样本对”一个窗口的过去值作为 X窗口之后的值作为 y。这一步在时序预测里叫滑窗思路和“python量化交易策略代码”里的特征截取是同一套逻辑只是目标从收益率换成了水质指标。import numpy as np def sliding_window(data, window_size24, pred_len1): 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_size24, pred_len1) print(X.shape, y.shape)window_size24表示用过去 24 小时预测未来这个值对应“一天”的周期是溶解氧预测里最常用的起点。pred_len1表示单步预测即预测下一个整点的 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(units64, return_sequencesTrue, input_shape(24, 1))) model.add(Dropout(0.2)) model.add(LSTM(units32, return_sequencesFalse)) model.add(Dropout(0.2)) model.add(Dense(units1)) model.compile(optimizeradam, lossmse, metrics[mae]) model.summary()第一层return_sequencesTrue很关键它让这一层输出每个时间步的隐状态而不是只输出最后一个这样才能把完整的时间步序列传给第二层 LSTM。第二层return_sequencesFalse只需要最后一个时间步的隐状态用来接全连接层输出预测值。input_shape(24, 1)里的 24 就是滑窗长度1 是特征数如果你后续加入温度、气压等变量这个 1 要改成对应的特征数量。units是隐状态维度64 和 32 是比较稳的起点。隐状态维度决定了 LSTM“记住多久”的容量太小记不住昼夜形态太大在数据量不足时容易过拟合。损失函数用mse对回归任务最直接训练过程中同时监控mae方便人眼解读误差量级。4.2 超参数怎么定一份能落地的参考表很多新手拿到源码第一件事是改网络结构但溶解氧预测的效果瓶颈通常不在结构而在滑窗长度、批量大小和学习率这几个参数上。我整理了一份常用的起始配置按这份配置跑出来的结果一般不会太离谱参数建议值说明window_size24 或 4824 对应一天周期48 包含昨天和今天可对照units64 / 32第一层 64第二层 32数据量大可翻倍dropout0.2防止过拟合数据量小可以提到 0.3batch_size64显存不够就降到 32影响收敛稳定性learning_rate1e-3Adam 默认值loss 震荡就降到 5e-4epochs200配合早停使用不用手动纠结轮数一个务实的建议先跑一次小规模实验比如 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_size1, hidden_size64, num_layers2, output_size1): super().__init__() self.lstm nn.LSTM( input_size, hidden_size, num_layers, batch_firstTrue, dropout0.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_firstTrue让输入形状变成 (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(monitorval_loss, patience15, restore_best_weightsTrue), ReduceLROnPlateau(monitorval_loss, factor0.5, patience5, min_lr1e-6), ModelCheckpoint(best_model.h5, monitorval_loss, save_best_onlyTrue) ] history model.fit( X_train, y_train, validation_data(X_val, y_val), epochs200, batch_size64, callbackscallbacks, verbose1 )patience15表示连续 15 个 epoch 验证集 loss 没有下降就停止训练这比固定 epochs 靠谱得多不需要你提前猜要训练多少轮。restore_best_weightsTrue是后悔药即使后面过拟合了也会把权重回滚到验证集表现最好的那个 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, eps1e-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(fRMSE{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, labelactual, linewidth2) plt.plot(y_pred_actual, labelpred, linewidth1.5, linestyle--) plt.legend(locupper right) plt.ylabel(DO (mg/L)) plt.tight_layout() plt.savefig(test_result.png, dpi150)看这张图时重点看三个位置凌晨低谷有没有跟住、峰值是不是被削平、曲线的整体相位有没有右移。低谷是增氧机启动的关键点如果预测值和实测峰谷对不上误差再小也没用。建议再画一张只包含凌晨 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), clipTrue)或者手动指定范围为 [0, 20]。另外定期用最近 90 天的数据重新拟合 scaler别让归一化参数永久冻结。6.4 训练 loss 直接变成 NaN模型几分钟就崩现象训练跑到第 2 或第 3 个 epochloss 突然变成 nan之后一直无法恢复。原因最常见的有两个一是原始数据里混入了 NaN 或超大异常值重采样插值时没处理干净二是学习率对当前数据分布太大梯度更新越过最优点后发散。解决先跑df.isna().sum()和df.describe()检查数据分布把异常值 clip 到物理范围。如果数据干净就把初始学习率从 1e-3 降到 1e-4或者在优化器上打开梯度裁剪Keras 里可以传clipnorm1.0给 Adam 优化器PyTorch 用torch.nn.utils.clip_grad_norm_。这条经验同样套用在其他时间序列预测场景不只是溶解氧。6.5 多站点数据混在一起训练模型被“平均”到谁的曲线都不像现象把 A 站和 B 站的水质记录拼在一起训练单站点验证时误差都不小模型像在两边之间取折中。原因不同水体的溶解氧基线差很多A 站长年平均 7 mg/LB 站可能常年只有 4 mg/L。混在一起训练后模型为了兼顾两边的 loss学到的是一个中间态的分布对谁都不精确。解决最稳妥的是每个站点单独建模反正单变量序列预测的数据量不大多训练几个模型成本也可控。如果一定要共用模型先按站点做标准化把各站数据都转成相对自身基线的偏差或者加一个站点 ID 类别特征让 LSTM 知道当前输入属于哪个水体。但说实话站点独立建模的收益通常更直接。7. 从预测曲线到预警动作验证与下一步部署7.1 上线前先跑一段“影子预测”模型在测试集上表现好不等于现场可靠。我习惯在真正接预警系统前先做两周影子预测模型照常输出但结果只记录不下发。每天把预测低谷和实测低谷对比计算“低谷命中误差”。如果连续一周低谷预测误差都在正负半小时以内再考虑接入自动控制这个方法成本低能挡住大部分模型不靠谱的风险。7.2 下一步投入产出比最高的方向加外部变量溶解氧不只受自身历史影响它和水温、气压、pH 高度联动。把这些变量一起送进模型输入特征从 1 变成多路LSTM 的input_shape相应改成(window_size, n_features)代码改动很小但对低谷预测的收益通常很明显。其次是定时重训用最近 90 天数据每两周重训一次避免模型随水质季节性变化而老化。这两个方向都比换更复杂的网络结构划算。7.3 一个我养成的验收习惯我现在跑完任何时序预测模型第一件事不是看测试集分数而是把预测曲线和原始曲线并排打开盯着看十分钟确认它抓到了凌晨那个低谷、峰值没有被削成平顶再决定要不要部署。这个习惯救过我很多次指标可能是会骗人的曲线不会。希望帮到你。本文还有配套的精品资源点击获取