☰
光伏功率预测实战:基于LSTM从数据清洗到PyTorch部署的完整方案
2026/10/9 4:25:44 网站建设 项目流程

简介:基于Python与LSTM的短期光伏预测算法实现资源,面向计算机、人工智能、通信工程、自动化等专业的在校学生与开发者,可用于毕设、课程设计或算法入门。资源围绕园区光伏数据展开,包含单变量、多变量光伏预测流程以及负荷预测LSTM示例,并延伸了基于规则集的储能框架,覆盖数据读取、特征处理、模型训练与结果可视化等关键环节。压缩包共11个文件,核心为6个Jupyter Notebook分步演示算法实现,另附1个可直接运行的Python脚本、1份Excel测试数据、2张实验过程图片和1份README说明文档,整体大小约3.89MB,目录结构清晰,便于按需查阅与二次开发。已有202人浏览学习,代码经作者测试运行成功,支持远程教学讲解,适合希望快速上手LSTM时序预测,或需要参考完整项目后自行扩展修改的初学者与进阶者。

1. 光伏预测为什么非LSTM不可:先说结论再给方法

做光伏功率预测的人都会遇到同一个尴尬:天气预报明明说晴天,中午云层一过,功率曲线直接跳水20%。这种辐照度突变,靠物理模型很难追上,因为云的运动本身就是一个混沌过程。而基于LSTM的短期光伏预测算法,恰好擅长捕捉这种“前一小时的变化趋势会延续到下一小时”的时间依赖,它不看绝对数值,看的是序列的演变模式。这套用Python实现的方案,输入历史功率、辐照度、温度,输出未来1到4小时的功率曲线,在中型电站的实测中,RMSE通常能压到装机容量的8%到12%。适合手里有SCADA或电站监控数据、想自己搭一套预测服务的新能源工程师和数据挖掘从业者。下文直接用可复现代码讲透整条链路,不绕弯子。

2. 短期光伏预测的选型逻辑:LSTM凭什么比传统RNN和Transformer更稳

2.1 光伏功率序列的三个统计特征决定了模型选型

先看数据再谈模型。光伏功率序列有三个绕不开的特征:强周期性、强自相关性、非平稳突变。日出日落让序列呈钟形曲线,所以每天的功率数据天然就是“两头低、中间高”;相邻两个采样点之间的功率值相关性极高,15分钟粒度下自相关系数常超过0.95;而云的遮挡会在几分钟内让功率跌掉一半以上,产生大量陡峭的阶跃点。

这三个特征直接排除了很多模型。线性回归把突变当噪声,扛不住;传统RNN对长序列的梯度消失问题让它在50步以上的输入上明显乏力;而Transformer理论上能捕捉长距离依赖,但光伏预测的训练样本通常只是单个电站的一年数据,几十万条级别,喂给动辄几千万参数的Transformer,很快就会过拟合,而且推理延迟在边缘端部署也不友好。LSTM通过门控机制记住了前一天同时刻的功率形态,又对短时突变敏感,是短期光伏预测里性价比最高的选择。

2.2 与GRU、TCN、Transformer的适用边界对比

模型参数量对突变响应训练速度边缘部署友好度在光伏短期预测上的典型表现
普通RNN小差,梯度消失快高预测曲线滞后明显
GRU小中,略好于RNN快高与LSTM接近但收敛慢
LSTM中好,门控能保留突变信息中高综合最优,工程落地最多
TCN中好,感受野可调中中适合长序列,但调参较敏感
Transformer大依赖注意力机制慢低数据不足时易过拟合

我做过的项目里,GRU和LSTM在15分钟粒度的超短期光伏功率预测上精度差距通常小于2%,但LSTM的PyTorch实现和文档资料更全,团队协作时沟通成本低,所以最终方案还是锁定了LSTM。如果你的场景是未来5分钟级别的超短期预测,TCN也可以纳入考虑,但如果目标是未来1小时以上,LSTM的稳定性更值得信赖。

2.3 输入输出窗口怎么定:从物理意义而不是拍脑袋出发

输入窗口长度不能随便定。光伏功率和辐照度的变化周期在晴天是平缓的钟形,在云天则是分钟级跳变。输入太长,模型学到的是“昨天同一时刻”的静态模式,对突变不敏感;输入太短,又丢失了趋势信息。常见做法是看数据的采样粒度与预测时效。15分钟采样的数据,预测未来1小时,输入窗口取前12个点(3小时)最稳妥;预测未来4小时,输入窗口可以放宽到前24个点(6小时),让模型有足够的上下文去判断云层移动的大方向。

输出窗口则按业务需求来。电力调度要求的是未来1小时到4小时的功率曲线,所以输出长度一般取4到16个点。输出超过16个点,误差会指数级放大,这是LSTM的马尔可夫性质决定的——它把预测值当成下一步的输入,误差会不断累积。因此不建议在单模型上强行拉长预测链条,后面第6章会讲怎么用多步策略去缓解。

3. 数据处理是决定预测精度的第一道分水岭:从SCADA原始表到滑窗样本的完整管线

3.1 原始数据清洗:剔除夜间和对零值时段,否则模型会被“零”带偏

很多人拿到电站数据就直接切开训练,结果模型在夜间输出一堆毛刺。原因很简单:夜间功率恒为0,这些零值在数据集里占了近一半,模型发现“输出0”就能拿到很低的损失,于是学会了偷懒。所以第一步必须把夜间和零辐照度时段剔除掉,只保留辐照度大于某阈值的样本。

import pandas as pd import numpy as np df = pd.read_csv('scada_pv_data.csv', parse_dates=['timestamp']) df['irradiance'] = df['ghi'].clip(lower=0) # 全局水平辐照度 # 剔除夜间与近乎零辐照度时段,阈值为 20 W/m^2 daytime_mask = df['irradiance'] > 20 df_daytime = df[daytime_mask].copy() # 剔除连续零功率超过30分钟的点,这类点通常是停机或传感器故障 zero_runs = (df_daytime['power'] == 0).astype(int).groupby( (df_daytime['power'] != 0).astype(int).cumsum() ).transform('sum') df_daytime = df_daytime[zero_runs <= 2] # 15分钟粒度,2个点等于30分钟

这段代码里有几个参数值得注意。辐照度阈值20 W/m²不是拍脑袋定的,日出日落前后的辐照度在10到50之间波动,低于20时组件输出本身就极低,对预测没有意义,但会污染样本分布。连续零功率的窗口保留2个点,原因是光伏组件在云遮住时功率骤降但不会是严格的0,如果连续两个点都是0,大概率是逆变器限电或通信中断,属于故障样本而非气象样本。

清洗之后务必做一步可视化检查:把清洗前后的功率分布图画出来对比,确认钟形曲线保留完整,没有把傍晚的低功率点误删。血泪经验是,阈值设太高会把春秋季节的弱光时段全部删光,导致训练集缺失低辐照度场景,雨天预测能力直接垮掉。

3.2 滑窗切分与特征拼接:造出LSTM需要的三维张量

清洗后的数据仍然是一维时间序列。LSTM的输入要求是(样本数, 时间步数, 特征数),所以需要一个滑窗函数把时序数据切成长度为seq_len的样本片段,并把未来pred_len个点的功率作为标签。

def make_sequence_samples(data, seq_len=12, pred_len=4, step=1): """ 构造滑窗样本。 data: DataFrame,包含特征列和功率列 seq_len: 输入时间步数 pred_len: 预测未来几个点 step: 滑动步长,建议1,保留最大样本量 """ feature_cols = ['irradiance', 'temperature', 'humidity', 'wind_speed', 'power'] X, y = [], [] values = data[feature_cols].values for i in range(len(values) - seq_len - pred_len + 1): X.append(values[i:i+seq_len, :]) # 过去seq_len步的全部特征 y.append(values[i+seq_len:i+seq_len+pred_len, -1]) # 未来pred_len步的功率 return np.array(X), np.array(y) X, y = make_sequence_samples(df_daytime, seq_len=12, pred_len=4) print('样本形状:', X.shape, y.shape) # 样本形状: (15432, 12, 5) (15432, 4)

这里一个关键的工程细节:特征列里把历史功率也放进去了。LSTM的自回归特性就在这里体现——它不仅要看辐照度预报,还要用功率的惯性来修正辐照度变化带来的偏差。如果你有数值天气预报(NWP)的辐照度预报数据,可以替换掉irradiance列,效果会更好,但注意NWP数据的时效性一般是小时级,和SCADA的分钟级数据做对齐时要按时间戳重采样。

滑窗的step参数默认设为1,不要为了减小数据集而设成2或3。光伏序列的相邻样本高度重叠,步长设为2会丢失大量突变细节,而降采样带来的信息损失比训练时间节省更得不偿失。如果你的数据量实在太大(比如秒级数据),先做15分钟聚合并插值补缺,而不是减少滑窗步长。

3.3 归一化的正确姿势:训练集fit,验证集和测试集只能transform

归一化是另一个翻车重灾区。常见错法是把整个数据集合并后做MinMaxScaler.fit(),这等于让模型在训练时就“偷看”了未来的最大值和最小值,测试集的分布信息被泄漏进训练过程,导致离线指标虚高,上线后直接崩。

from sklearn.preprocessing import MinMaxScaler feature_cols = ['irradiance', 'temperature', 'humidity', 'wind_speed', 'power'] scaler = MinMaxScaler() # 先划分再归一化:按时间顺序切,不能随机切 train_ratio, val_ratio = 0.7, 0.15 train_len = int(len(X_total) * train_ratio) val_len = int(len(X_total) * val_ratio) X_train, X_val, X_test = X_total[:train_len], X_total[train_len:train_len+val_len], X_total[train_len+val_len:] y_train, y_val, y_test = y_total[:train_len], y_total[train_len:train_len+val_len], y_total[train_len+val_len:] # 关键:scaler只用训练集拟合,验证/测试集只transform scaler.fit(X_train.reshape(-1, X_train.shape[-1])) X_train_norm = scaler.transform(X_train.reshape(-1, X_train.shape[-1])).reshape(X_train.shape) X_val_norm = scaler.transform(X_val.reshape(-1, X_val.shape[-1])).reshape(X_val.shape) X_test_norm = scaler.transform(X_test.reshape(-1, X_test.shape[-1])).reshape(X_test.shape) # y同样处理 y_scaler = MinMaxScaler() y_scaler.fit(y_train.reshape(-1, 1)) y_train_norm = y_scaler.transform(y_train.reshape(-1, 1)).reshape(y_train.shape)

注意这里的二维转三维再转回来的操作:因为MinMaxScaler只接受二维输入,而LSTM需要三维,所以必须reshape过渡。y_train归一化时单独用了另一个scaler,这个细节很多人会漏掉——功率和辐照度的量纲不同,如果共用同一个scaler,功率的数值范围会被辐照度带偏。训练时用归一化后的数据,评估时记得inverse_transform还原成功率值(单位kW),再算误差。

4. 构建LSTM预测模型:PyTorch实现与必调参数详解

4.1 网络结构设计:双层LSTM加全连接输出,不加复杂注意力

LSTM的模型结构不需要花哨。基于我在光伏场景的实践经验,双层LSTM加一层全连接输出,效果已经超过了市面上大部分加注意力机制的变体。原因是光伏序列的依赖模式比较固定——前一天同时段、前一小时趋势、当前辐照度突变——这些模式用两层的隐藏状态就能表达,加注意力反而引入额外参数,数据量不够时会过拟合。

import torch import torch.nn as nn class PVLSTM(nn.Module): def __init__(self, input_size, hidden_size, num_layers, output_len, 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.regressor = nn.Sequential( nn.Linear(hidden_size, hidden_size // 2), nn.ReLU(), nn.Dropout(dropout), nn.Linear(hidden_size // 2, output_len) ) def forward(self, x): # x: (batch, seq_len, input_size) lstm_out, (h_n, c_n) = self.lstm(x) # 取最后一层最后一个时间步的隐藏状态 last_hidden = h_n[-1] # (batch, hidden_size) out = self.regressor(last_hidden) return out

这里的结构有两个设计要点。使用h_n[-1]而不是lstm_out[:, -1, :],因为lstm_out的最后一维是每个时间步的输出,取最后一个时间步等价于看整个序列编码后的最终状态,而h_n[-1]是最后一个LSTM层的隐藏状态,两者多数情况下语义相近,但h_n[-1]更明确地表达“整个序列的总结”。regressor里加了一个中间全连接层而不是直接线性映射,是为了让模型有足够的非线性去拟合功率的钟形曲线。

4.2 训练配置:Huber损失对抗突变,早停防止过拟合

损失函数的选择直接决定模型对突变的敏感度。MSE会对云遮导致的极端误差值给予过大的惩罚,让模型宁可把预测值折中,也不愿做出锐利的突变响应。Huber损失在误差小于阈值时是平方损失,大于阈值时是线性损失,对离群点天然稳健。

import torch.optim as optim from torch.utils.data import DataLoader, TensorDataset def train_model(model, X_train, y_train, X_val, y_val, epochs=80, batch_size=64, lr=1e-3): device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model.to(device) train_dataset = TensorDataset(torch.FloatTensor(X_train), torch.FloatTensor(y_train)) val_dataset = TensorDataset(torch.FloatTensor(X_val), torch.FloatTensor(y_val)) train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True) val_loader = DataLoader(val_dataset, batch_size=batch_size, shuffle=False) optimizer = optim.Adam(model.parameters(), lr=lr, weight_decay=1e-5) scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode='min', factor=0.5, patience=5) criterion = nn.HuberLoss(delta=1.0) best_val_loss = float('inf') patience_counter = 0 for epoch in range(epochs): model.train() train_loss = 0.0 for batch_x, batch_y in train_loader: batch_x, batch_y = batch_x.to(device), batch_y.to(device) optimizer.zero_grad() pred = model(batch_x) loss = criterion(pred, batch_y) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() train_loss += loss.item() * batch_x.size(0) train_loss /= len(train_loader.dataset) model.eval() val_loss = 0.0 with torch.no_grad(): for batch_x, batch_y in val_loader: batch_x, batch_y = batch_x.to(device), batch_y.to(device) pred = model(batch_x) loss = criterion(pred, batch_y) val_loss += loss.item() * batch_x.size(0) val_loss /= len(val_loader.dataset) scheduler.step(val_loss) if val_loss < best_val_loss: best_val_loss = val_loss patience_counter = 0 torch.save(model.state_dict(), 'best_pv_lstm.pth') else: patience_counter += 1 if patience_counter >= 10: break print(f'Epoch {epoch+1:03d}: train_loss={train_loss:.5f}, val_loss={val_loss:.5f}')

训练配置里有几个参数是光伏预测特有的调参经验。HuberLoss(delta=1.0)的delta设1.0,意味着归一化后的误差绝对值在1以内按平方处理,超过1按线性处理——这个阈值正好对应功率突变前后误差明显放大的场景。clip_grad_norm_(max_norm=1.0)是防止某个突变的batch把梯度撑爆,光伏序列中偶发强突变会导致loss局部激增,不裁剪梯度的话模型可能直接训练发散。ReduceLROnPlateau的factor设0.5、patience设5,意思是验证损失连续5轮不下降就降一半学习率,比固定学习率衰减更适合光伏这种非平稳数据。

4.3 评估指标:RMSE、MAE和R²之外,必须看归一化RMSE

光看RMSE没有意义,因为不同装机容量的电站RMSE差好几倍。行业里常用的对比指标是归一化RMSE(nRMSE),计算公式为sqrt(mean((y_true - y_pred)^2)) / (P_installed),即用RMSE除以电站装机容量。这个值能跨电站比较模型优劣。

from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score def evaluate_model(model, X_test, y_test, y_scaler, installed_capacity): device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model.eval() with torch.no_grad(): pred = model(torch.FloatTensor(X_test).to(device)).cpu().numpy() # 还原真实功率值 pred_real = y_scaler.inverse_transform(pred.reshape(-1, 1)).reshape(pred.shape) y_test_real = y_scaler.inverse_transform(y_test.reshape(-1, 1)).reshape(y_test.shape) rmse = np.sqrt(mean_squared_error(y_test_real.flatten(), pred_real.flatten())) mae = mean_absolute_error(y_test_real.flatten(), pred_real.flatten()) r2 = r2_score(y_test_real.flatten(), pred_real.flatten()) nrmse = rmse / installed_capacity print(f'RMSE: {rmse:.2f} kW') print(f'MAE: {mae:.2f} kW') print(f'R²: {r2:.4f}') print(f'nRMSE: {nrmse:.4f} ({nrmse*100:.2f}%)') return {'rmse': rmse, 'mae': mae, 'r2': r2, 'nrmse': nrmse}

nRMSE小于10%说明模型已经具备实用价值,10%到15%是正常水平,超过20%就要回头查数据处理了。评估时注意把model.eval()和torch.no_grad()都打开,否则dropout层会在推理时随机丢弃节点,导致多次评估结果波动。如果发现R²很高但nRMSE也很高,大概率是测试集里包含了太多低功率时段,R²被大量接近0的真值拉高了,单独看R²会被误导。

5. 常见问题排查:五个让预测翻车的高频坑

5.1 夜间数据没删干净,模型学会了「躺平」

现象:夜间预测输出不为0,而是在0附近随机抖动,白天预测曲线整体偏低。测试集nRMSE异常高。

原因:数据清洗时删夜间数据用的是时间判断(比如hour < 6),但不同季节日出时间差两小时,固定时间截断会留下大量弱光时段。模型看到输入特征是低辐照度,直接输出一个接近0的值就能拿到较低的损失,于是权重被“教坏”了。

解决:不要按时间判断,按辐照度判断。统一用irradiance > 20做mask,且把温度低于-10℃的极端天气样本一并剔除,因为低温下组件IV曲线畸变,功率与辐照度的线性关系不再成立。

5.2 归一化时数据泄漏,离线精度虚高

现象:训练时验证集loss很低,但拿到新数据上预测,误差比测试集高出一倍不止。

原因:对全量数据做了scaler.fit(),验证集和测试集的最大值、最小值参与了归一化的统计计算。模型在训练时见过这些极值的“形状”,等于把未来信息泄漏给了参数更新方向。

解决:严格按时间顺序划分训练、验证、测试集后,再对训练集做fit,其余两个集只做transform。同时检查MinMaxScaler的data_range_属性,确认拟合所用数据的极值与原始真实范围一致。

5.3 滑窗时随机shuffle,把未来信息灌进了训练集

现象:模型验证集指标极好,但前一天的数据预测第二天的数据时误差明显放大。

原因:滑窗生成样本后直接调了DataLoader(shuffle=True),而样本本身就带着时间属性,随机打乱后,训练集里混入了测试时间段内的样本片段,模型在训练时就见过了“未来的天气”。

解决:先按时间顺序切出训练、验证、测试区间,只在训练集的DataLoader里开shuffle=True,验证和测试集保持时间顺序。判断代码是否正确,可以检查torch.FloatTensor(X_train)里的时间戳列是否严格连续且小于验证集的最小时间戳。

5.4 预测曲线整体滞后一个时间步

现象:绘制的预测曲线形状正确,但相对于真实值整体向右平移了一个采样点,像错了一位。

原因:LSTM学到的是“上一时刻的功率延续”,本质上是把t时刻的功率值复制到t+1时刻,这种自回归式滞后在时序预测里非常常见。通常是因为输入窗口太短、噪声太大,模型找不到比“复制”更强的特征。

解决:首先把输入窗口从12个点加长到24个点,让模型看到更长的趋势;然后检查损失函数是否被低功率样本主导,尝试对训练样本按功率值加权;最后考虑在特征里加入辐照度预报的数值,外生变量是缓解滞后最有效的手段。

5.5 辐照度传感器脏污导致坏点,模型拟合到噪声上

现象:训练loss正常,但验证loss在某几个时段突然飙升,单独看那些时段的数据,辐照度曲线有毛刺。

原因:电站现场的辐照度传感器长期风吹日晒,表面落灰或鸟粪会导致读数偏低,但功率曲线是真实的光伏输出,两者出现矛盾。模型试图同时拟合辐照度和功率,会被脏污时段的数据反复拉扯。

解决:增加一个前置的异常检测步骤,用滑动窗口计算辐照度与功率的相关系数,当窗口内相关系数低于0.7就标记为可疑样本,人工确认后剔除。同时可以用功率除以辐照度得到“系统效率比”,这个比值超出正常区间(比如0.05到0.9)的点直接删掉。

6. 上线前的最后一公里:多步预测验证与模型瘦身

短期光伏预测的工程落地,真正考验人的是部署阶段。网格化预测的误差累积是首要问题——单步预测nRMSE只有8%,迭代预测4小时后可以飙到18%。解决思路是分而治之:对第1小时用直接多步预测(模型同时输出4个点),对第2到4小时用迭代多步预测(把前一小时的预测输出拼到输入里继续预测)。实测中这种混合策略比纯迭代误差低3到5个百分点。

部署前的验证不要用全量测试集指标糊弄自己,要按天气类型分别统计:晴天、多云、雨天、阵雨四种场景的nRMSE各自是多少。雨天如果nRMSE超过25%,说明模型对低辐照度突变的学习不足,需要补充特征或增加雨天样本的权重。这个分类验证脚本值得花一天时间写,它能帮你精准定位模型的短板。

模型瘦身方面,LSTM本身参数不多,一个两层、hidden_size=64的模型只有几十万个参数,不需要做量化剪枝。真正的性能瓶颈在预处理环节——建议把滑窗切片和归一化写成可并行的numpy版本,或者用Polars替换pandas,数据读取速度能提升5倍以上。推理阶段用batch推理,一次处理当天全部采样点,比单点预测快一个数量级。

我自己的习惯是每次训练完把验证集上预测最差的那天画成图,打印出来贴在工位上研究两天。有一次发现连续雨天预测偏低,排查了两天后发现是清洗时把低辐照度样本删过头了,导致雨天样本只有晴天的十分之一。调整数据配比后,整体nRMSE从13.5%降到了10.8%。这个教训让我记住了:光伏预测的瓶颈通常在数据侧而不在模型侧——模型是工具,数据才是地基。希望这篇从数据清洗到部署验证的完整拆解,能帮你少走几个月的弯路,让你的LSTM预测模型真正跑起来、扛得住电站现场的各种天气刁难。

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

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

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

立即咨询