☰
基于LSTM的景泰小区水产量预测与可视化系统实战
2026/10/3 3:04:16 网站建设 项目流程

简介:这份资源是面向数据科学学习者与时间序列预测开发者的完整项目源码,围绕景泰小区水产量预测场景,采用LSTM长短期记忆网络对历史水量数据进行建模与预测,适合作为课程设计、毕业设计或机器学习实战练习的参考方案。压缩包共156个文件,约16.74MB,其中包含27个Python脚本负责数据处理、模型构建与训练,50个pth权重文件保存训练好的模型参数,另有HTML、JavaScript与CSS文件构成网页端交互界面,配合yaml配置、png图表、sqlite3数据库及csv数据集等资源,形成从数据到预测再到可视化展示的完整链路。目前已有276人学习下载。读者可从中获取LSTM时间序列预测的完整实现思路、模型训练与保存流程、前后端页面组织方式以及项目依赖与配置管理方法,便于快速理解并复现一个可运行的水产量预测系统。

1. 景泰小区水产量预测:从 LSTM 模型到可视化页面的完整落地路径

景泰小区的物业运维团队每个月都要面对同一个问题:下个月到底该向供水公司报多少用水计划。报多了,水压过高、管网损耗大;报少了,高层住户晚上洗澡水压不够,投诉电话直接打爆。过去靠老师傅拍脑袋,误差经常在 15% 以上。这套「基于 LSTM 模型的景泰小区水产量预测」方案,就是用 Python 把历史用水数据喂给 LSTM 神经网络,训练出一个能预测未来 7 到 30 天水产量的模型,再用 HTML、CSS、JavaScript 搭一个可视化页面,让物业值班人员打开浏览器就能看到预测曲线和明日建议供水量。它适合两类人:一类是想找一个完整时间序列预测项目练手的 Python 学习者,另一类是真的需要给小区、园区做用水量预测的运维工程师。整套东西不依赖云服务,一台普通办公电脑就能跑起来。

2. LSTM 为什么适合水产量预测:原理、选型与数据准备

2.1 水产量数据的三个特性决定了不能用普通回归

景泰小区的日用水量数据有三个明显特征。第一是周期性,工作日和周末的用水曲线完全不同,周末上午的用水高峰比工作日推迟大约两小时。第二是趋势性,夏季用水量整体高于冬季,且随着入住率提升,年均用水量在缓慢爬升。第三是突变性,遇到停水检修、暴雨天气或者小区举办大型活动,当天用水量会出现明显偏离。

普通线性回归或者 ARIMA 模型处理周期性还行,但面对「前 30 天的用水模式共同影响今天」这种长距离依赖,就力不从心了。LSTM(长短期记忆网络)的门控机制天生就是为这种场景设计的:遗忘门决定丢弃哪些旧信息,输入门决定记住哪些新信息,输出门决定当前输出什么。用水量的周期规律会被长期保留在细胞状态里,而突发的停水事件则通过输入门短暂影响预测,不会污染长期记忆。

提示:如果你的数据量少于 200 条日记录,LSTM 很容易过拟合,这时候用 Prophet 或者简单的季节性分解反而更稳。景泰小区建议至少积累 1 年以上的日用水数据再上 LSTM。

2.2 数据采集与清洗:把物业 Excel 变成模型能吃的格式

物业手里的原始数据通常是一张 Excel 表,字段包括日期、抄表读数、备注。抄表读数需要做差分才能得到日用水量。下面这段 Python 代码完成从 Excel 读取到日用水量序列的转换,并处理缺失值和异常值。

import pandas as pd import numpy as np # 读取物业提供的抄表记录,假设字段为 date 和 reading df = pd.read_excel("jingtai_water.xlsx", parse_dates=["date"]) df = df.sort_values("date").reset_index(drop=True) # 差分得到日用水量,单位立方米 df["usage"] = df["reading"].diff() # 处理抄表间隔不规律的情况:超过 2 天的间隔按日均值填充 df["gap_days"] = df["date"].diff().dt.days mask = df["gap_days"] > 1 df.loc[mask, "usage"] = df.loc[mask, "usage"] / df.loc[mask, "gap_days"] # 异常值处理:用水量超过均值 3 倍标准差的视为异常,用前后均值替换 mean, std = df["usage"].mean(), df["usage"].std() df["usage"] = np.where( np.abs(df["usage"] - mean) > 3 * std, df["usage"].rolling(7, min_periods=1, center=True).mean(), df["usage"] ) # 缺失值前向填充后再后向填充 df["usage"] = df["usage"].ffill().bfill() df[["date", "usage"]].to_csv("clean_usage.csv", index=False) print(df["usage"].describe())

这段代码的关键参数有三个。gap_days用来识别抄表间隔,景泰小区如果是每周抄一次表,这个值就是 7,差分后必须除以间隔天数才能还原真实日均用水量。3 * std是异常值阈值,如果小区数据本身波动就大,可以放宽到 4 倍。rolling(7)的窗口选择 7 天,是因为用水量的周周期最强,用一周的均值替换异常点最符合业务直觉。

清洗完成后,建议先画一张时序图肉眼检查。如果看到连续多天用水量为零,那多半是抄表员漏抄或者整段数据缺失,需要回退到原始记录核对,不要直接交给模型。

2.3 滑动窗口构造与归一化:LSTM 输入张量的正确形状

LSTM 要求的输入是三维张量,形状为(样本数, 时间步长, 特征数)。水产量预测通常用过去 30 天预测未来 1 天或 7 天。下面代码把清洗后的序列切成监督学习样本。

import numpy as np from sklearn.preprocessing import MinMaxScaler data = pd.read_csv("clean_usage.csv")["usage"].values.reshape(-1, 1) # 归一化到 0-1,LSTM 对输入尺度敏感 scaler = MinMaxScaler(feature_range=(0, 1)) scaled = scaler.fit_transform(data) def make_sequences(series, lookback=30, horizon=1): X, y = [], [] for i in range(len(series) - lookback - horizon + 1): X.append(series[i:i + lookback]) y.append(series[i + lookback:i + lookback + horizon]) return np.array(X), np.array(y) LOOKBACK = 30 # 用过去 30 天 HORIZON = 7 # 预测未来 7 天 X, y = make_sequences(scaled, LOOKBACK, HORIZON) print(X.shape, y.shape) # 例如 (330, 30, 1) (330, 7, 1) # 按时间顺序划分,不能随机打乱 split = int(len(X) * 0.8) X_train, X_test = X[:split], X[split:] y_train, y_test = y[:split], y[split:]

LOOKBACK=30是经验值,对应一个月的用水记忆。如果小区有明显的季度规律,可以加大到 90。HORIZON=7表示一次预测一周,物业排班刚好按周走。归一化必须用训练集的 min 和 max,测试集只能 transform 不能 fit,否则会造成数据泄露,这是时间序列里最常见的翻车点之一。

3. 用 PyTorch 搭一个能跑通的 LSTM 预测模型

3.1 模型结构定义:两层 LSTM 加全连接输出

PyTorch 的 LSTM 模块封装得很好,但水产量预测这种小数据集不需要堆太深。两层 LSTM、隐藏层 64 维、dropout 0.2 就足够。下面是一个可直接运行的模型定义。

import torch import torch.nn as nn class WaterLSTM(nn.Module): def __init__(self, input_size=1, hidden_size=64, num_layers=2, output_size=7, dropout=0.2): super().__init__() self.lstm = nn.LSTM( input_size=input_size, hidden_size=hidden_size, num_layers=num_layers, batch_first=True, dropout=dropout ) self.fc = nn.Linear(hidden_size, output_size) def forward(self, x): # x: (batch, lookback, 1) out, (h_n, c_n) = self.lstm(x) # 取最后一个时间步的隐藏状态 last = out[:, -1, :] return self.fc(last) model = WaterLSTM() print(sum(p.numel() for p in model.parameters())) # 参数量约 5 万

batch_first=True让输入维度顺序变成(batch, seq, feature),和前面构造的数据一致。num_layers=2是两层堆叠,第一层输出作为第二层输入。dropout=0.2只在层与层之间生效,如果只有一层 LSTM,这个参数会被忽略。输出层直接映射到 7 维,对应未来 7 天。

参数量控制在 5 万左右是有意为之。景泰小区一年也就 365 条数据,切完样本不到 340 条,参数量再大就会把训练集背下来,测试集一塌糊涂。

3.2 训练循环与损失曲线观察

训练代码不复杂,但有几个细节决定成败:学习率用 1e-3、优化器用 Adam、损失函数用 MSE,并且要记录验证集损失。

from torch.utils.data import TensorDataset, DataLoader train_ds = TensorDataset(torch.FloatTensor(X_train), torch.FloatTensor(y_train)) train_loader = DataLoader(train_ds, batch_size=32, shuffle=True) device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = WaterLSTM().to(device) criterion = nn.MSELoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) EPOCHS = 200 best_loss = float("inf") for epoch in range(EPOCHS): model.train() total = 0 for xb, yb in train_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) train_loss = total / len(train_ds) model.eval() with torch.no_grad(): val_pred = model(torch.FloatTensor(X_test).to(device)) val_loss = criterion(val_pred, torch.FloatTensor(y_test).to(device)).item() if val_loss < best_loss: best_loss = val_loss torch.save(model.state_dict(), "best_lstm.pt") if epoch % 20 == 0: print(f"Epoch {epoch}: train={train_loss:.5f}, val={val_loss:.5f}")

clip_grad_norm_的max_norm=1.0是 LSTM 训练的后悔药。没有它,遇到异常样本时梯度可能瞬间爆炸,损失直接变成 nan。batch_size=32在 300 多条样本下大约每轮 9 个 batch,训练很快。EPOCHS=200配合早停保存最优模型,避免过拟合。

训练时重点看验证损失曲线。如果训练损失一直降、验证损失先降后升,说明过拟合了,解决办法是减小 hidden_size 或者加大 dropout。如果两条曲线都降不下去,检查归一化是不是做错了,或者 lookback 设得太短。

3.3 预测结果反归一化与误差评估

模型输出的是 0 到 1 之间的数,必须反归一化才能变成立方米。评估指标用 MAE 和 MAPE,比 MSE 更直观。

model.load_state_dict(torch.load("best_lstm.pt")) model.eval() with torch.no_grad(): pred_scaled = model(torch.FloatTensor(X_test).to(device)).cpu().numpy() # 反归一化:scaler 是按单列 fit 的,直接 inverse_transform pred = scaler.inverse_transform(pred_scaled) true = scaler.inverse_transform(y_test) mae = np.mean(np.abs(pred - true)) mape = np.mean(np.abs((pred - true) / true)) * 100 print(f"MAE={mae:.2f} 立方米, MAPE={mape:.2f}%")

景泰小区这类数据,MAPE 能压到 8% 以内就算可用。如果超过 15%,先别急着调模型,回头检查数据里有没有连续多天的零值或者抄表错误。很多所谓的「模型效果差」,根子都在数据质量上。

4. 前端可视化:HTML、CSS、JavaScript 把预测结果搬到浏览器

4.1 页面骨架与 CSS 布局:让值班人员一眼看懂

物业值班人员不需要花哨的界面,他们要的是打开就能看到「明天建议供水多少」和「未来一周趋势」。页面用最简单的三栏布局:顶部标题、中间图表、底部数据表。CSS 用 flex 布局,不引入任何框架。

<!DOCTYPE html> <html lang="zh-cn"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>景泰小区水产量预测</title> <style> body { font-family: "Microsoft YaHei", sans-serif; margin: 0; background: #f5f7fa; } .header { background: #2c6e9b; color: #fff; padding: 16px 24px; font-size: 20px; } .container { display: flex; flex-wrap: wrap; gap: 16px; padding: 16px; } .card { background: #fff; border-radius: 8px; padding: 16px; flex: 1 1 300px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); } .card h3 { margin: 0 0 12px; font-size: 16px; color: #333; } .value { font-size: 32px; color: #2c6e9b; font-weight: bold; } table { width: 100%; border-collapse: collapse; } th, td { padding: 8px; border-bottom: 1px solid #eee; text-align: left; } th { color: #666; font-weight: normal; } </style> </head> <body> <div class="header">景泰小区水产量预测看板</div> <div class="container"> <div class="card"> <h3>明日建议供水量</h3> <div class="value" id="tomorrow">--</div> <div>立方米</div> </div> <div class="card"> <h3>未来 7 天预测</h3> <canvas id="chart" width="600" height="300"></canvas> </div> <div class="card"> <h3>预测明细</h3> <table id="detail"><thead><tr><th>日期</th><th>预测用量</th></tr></thead><tbody></tbody></table> </div> </div> <script src="predict.js"></script> </body> </html>

CSS 里flex: 1 1 300px让卡片在窄屏自动换行,值班室的老显示器也能正常显示。box-shadow用得很轻,避免视觉干扰。颜色选#2c6e9b这种偏稳重的蓝,符合水务场景。

4.2 JavaScript 读取预测数据并渲染图表

后端 Python 训练完模型后,把预测结果导出成 JSON 文件,前端用 fetch 读取。图表不引入 ECharts 这种大库,直接用 Canvas 画折线,减少依赖。

// predict.js async function loadPrediction() { const res = await fetch('prediction.json'); const data = await res.json(); // { dates: [...], values: [...] } // 更新明日建议供水量 document.getElementById('tomorrow').textContent = data.values[0].toFixed(1); // 填充明细表 const tbody = document.querySelector('#detail tbody'); tbody.innerHTML = data.dates.map((d, i) => `<tr><td>${d}</td><td>${data.values[i].toFixed(1)} 立方米</td></tr>` ).join(''); drawChart(data); } function drawChart(data) { const canvas = document.getElementById('chart'); const ctx = canvas.getContext('2d'); const W = canvas.width, H = canvas.height; const pad = 40; const max = Math.max(...data.values) * 1.1; const min = Math.min(...data.values) * 0.9; ctx.clearRect(0, 0, W, H); // 坐标轴 ctx.strokeStyle = '#ccc'; ctx.beginPath(); ctx.moveTo(pad, pad); ctx.lineTo(pad, H - pad); ctx.lineTo(W - pad, H - pad); ctx.stroke(); // 折线 ctx.strokeStyle = '#2c6e9b'; ctx.lineWidth = 2; ctx.beginPath(); data.values.forEach((v, i) => { const x = pad + (W - 2 * pad) * i / (data.values.length - 1); const y = H - pad - (H - 2 * pad) * (v - min) / (max - min); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); }); ctx.stroke(); // 数据点 ctx.fillStyle = '#2c6e9b'; data.values.forEach((v, i) => { const x = pad + (W - 2 * pad) * i / (data.values.length - 1); const y = H - pad - (H - 2 * pad) * (v - min) / (max - min); ctx.beginPath(); ctx.arc(x, y, 4, 0, Math.PI * 2); ctx.fill(); }); } loadPrediction();

fetch('prediction.json')要求页面通过 HTTP 服务打开,直接双击 HTML 文件会因为跨域限制读不到 JSON。最简单的办法是在项目目录下运行python -m http.server 8000,然后浏览器访问localhost:8000。Canvas 的坐标换算里,pad=40留出边距,max和min各留 10% 余量,避免折线贴边。

4.3 Python 端导出预测 JSON 的脚本

训练完模型后,用下面脚本生成前端需要的 JSON 文件,日期从明天开始连续 7 天。

import json from datetime import datetime, timedelta # pred 是反归一化后的 7 个预测值,形状 (7,) start = datetime.now() + timedelta(days=1) dates = [(start + timedelta(days=i)).strftime("%Y-%m-%d") for i in range(7)] result = { "dates": dates, "values": [round(float(v), 1) for v in pred.flatten()] } with open("prediction.json", "w", encoding="utf-8") as f: json.dump(result, f, ensure_ascii=False, indent=2) print("已导出 prediction.json")

ensure_ascii=False保证中文日期正常显示。round(float(v), 1)保留一位小数,和前端toFixed(1)对应。这个 JSON 文件就是 Python 和 JavaScript 之间的唯一接口,两边解耦,后续换模型或者换前端都不影响对方。

5. 避坑与排查:水产量预测项目里最容易翻车的 5 个地方

5.1 现象:模型预测值几乎是一条直线

原因:归一化时用了整个数据集的 min 和 max,或者 LSTM 的 hidden_size 太小导致模型根本没学到周期。更隐蔽的一种情况是,滑动窗口构造时lookback设成了 1,模型只能看到昨天,自然预测不出周期。

解决:确认scaler.fit_transform只在训练集上调用,测试集用transform。把lookback调到 30 以上,hidden_size至少 32。打印几条训练样本的 X 和 y,肉眼确认输入输出对应关系正确。

5.2 现象:训练损失降到很低,但测试集 MAPE 超过 30%

原因:数据泄露。最常见的是先对整个序列做归一化再切分训练测试,或者滑动窗口切分时测试集样本和训练集有重叠。另一个原因是数据里有未来信息,比如用当天的抄表读数预测当天用水量。

解决:严格按时间顺序切分,先切分再归一化。检查make_sequences函数,确保测试集的第一个窗口的起始索引大于训练集最后一个窗口的结束索引。如果数据量实在少,用时间序列交叉验证,不要用随机 K 折。

5.3 现象:前端页面打开一片空白,控制台报 CORS 错误

原因:直接双击 HTML 文件,浏览器用file://协议加载,fetch 请求prediction.json被同源策略拦截。

解决:在项目目录运行python -m http.server 8000,通过http://localhost:8000访问。如果部署到服务器,确保 JSON 文件和 HTML 同域,或者后端接口加上Access-Control-Allow-Origin头。

5.4 现象:预测结果全是零或者负数

原因:反归一化时用错了 scaler。如果训练时是对差分后的数据归一化,预测完还要做逆差分才能还原成原始用水量。另一个原因是模型输出层没有激活函数,理论上可以输出负数,但用水量不可能为负。

解决:在输出层后加torch.relu或者torch.clamp(min=0)。检查归一化和反归一化的配对关系,差分过的数据要累加回去。景泰小区的数据建议直接对原始用水量归一化,不做差分,减少一层转换。

5.5 现象:模型在夏季数据上表现好,冬季一塌糊涂

原因:训练数据没有覆盖完整的季节周期。如果只用 6 个月数据训练,模型没见过冬季用水模式,泛化能力自然差。LSTM 虽然能记长序列,但没见过的模式它编不出来。

解决:至少用 12 个月数据训练,并且在特征里加入月份或温度作为辅助输入。如果数据不够,用数据增强,比如对夏季数据加噪声模拟冬季波动,但效果有限,最靠谱的还是积累数据。

6. 把预测精度再压 2 个点:多变量输入与滚动预测的实战技巧

单变量 LSTM 做到 MAPE 8% 左右就遇到瓶颈了。想再往下压,最有效的办法是引入外部变量。景泰小区可以拿到三个额外特征:日最高气温、是否节假日、前一天是否停水检修。把这三个特征和用水量拼在一起,输入维度从 1 变成 4,模型能学到「气温超过 35 度用水量涨 12%」这种规律。

# 多变量输入:usage, temp, is_holiday, is_maintenance features = df[["usage", "temp", "is_holiday", "is_maintenance"]].values scaler = MinMaxScaler() scaled = scaler.fit_transform(features) # 注意:所有列一起归一化 def make_multi_sequences(series, lookback=30, horizon=7): X, y = [], [] for i in range(len(series) - lookback - horizon + 1): X.append(series[i:i + lookback]) # (lookback, 4) y.append(series[i + lookback:i + lookback + horizon, 0]) # 只预测 usage return np.array(X), np.array(y)

模型定义只需把input_size改成 4,其余不变。注意 y 只取第 0 列,因为气温和节假日不需要预测。归一化时所有列一起 fit,反归一化时只对第 0 列做逆变换,这里容易搞混,建议单独保存 usage 列的 scaler。

另一个技巧是滚动预测。一次预测 7 天,误差会累积,第 7 天的精度通常比第 1 天差一截。改成每次只预测 1 天,然后把预测值填回输入序列,再预测下一天。这样每一步都基于最新的预测结果,虽然计算量增加 7 倍,但 MAPE 通常能再降 1 到 2 个点。

def rolling_predict(model, last_window, steps=7): preds = [] window = last_window.copy() # (1, lookback, 4) for _ in range(steps): with torch.no_grad(): p = model(torch.FloatTensor(window).to(device)).cpu().numpy() preds.append(p[0, 0]) # 把预测值填入窗口末尾,其余特征沿用最后一天的值 new_row = window[0, -1].copy() new_row[0] = p[0, 0] window = np.concatenate([window[:, 1:, :], new_row.reshape(1, 1, -1)], axis=1) return np.array(preds)

滚动预测的坑在于误差会累积,如果某一步预测偏得离谱,后面全跟着偏。我的习惯是给每一步的预测值加一个 0.9 的衰减系数,让模型稍微保守一点,实际用水量通常不会突然暴涨。这个系数根据小区历史最大日波动来定,景泰小区取 0.9 比较合适。

验证方法上,不要只看 MAPE。把预测值和真实值画在同一张图上,重点看峰值和谷值是否对齐。如果峰值总是预测偏低,说明模型对极端情况不敏感,可以在损失函数里给大值样本更高权重。我一般用加权 MSE,权重设为真实值的平方根,这样模型会更关注用水高峰。

最后说一个血泪教训:模型上线后不要直接替换人工排班,先并行运行一个月,每天对比预测值和实际值,记录偏差超过 15% 的日子,回头分析原因。景泰小区第一次上线时,因为没把春节假期单独处理,大年初一的预测值比实际高了 40%,后来把节假日特征加进去才解决。数据预测这件事,模型只是工具,对业务的理解才是核心。希望帮到你。

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

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

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

立即咨询