做光伏发电短期功率预测这个项目时,我最深的感受是:模型从来不是天花板,数据才是。这个项目最终对比了LSTM、TCN、CNN-LSTM、TCN-LSTM四种模型,从数据清洗、特征工程、模型搭建到部署推理完整跑了一遍。如果你正准备做时间序列预测,或者想了解光伏功率预测到底怎么落地、不同模型之间的差距有多大,这篇内容值得你花十分钟看完。我会把项目里的关键思路、模型设计细节、踩过的坑和最终实验结论一次讲清楚。
1. 项目定位与问题拆解
1.1 光伏功率预测要解决什么现实问题
光伏发电短期功率预测,简单说就是根据历史发电数据、气象数据,预测未来几小时甚至未来一两天的光伏电站输出功率。为什么这件事重要?因为光伏发电受天气影响极大,一片云飘过来,功率可能瞬间掉一半。电网调度需要提前知道电站能发多少电,否则就得准备大量备用容量,成本很高;电站自己也需要预测功率来做交易申报、设备维护计划。做的预测越准,电网运行越稳,电站收益也越高。
这个项目里,我用的数据来自一个装机容量10MW的地面光伏电站,时间分辨率是15分钟一个点,历史数据长度约一年。预测目标是未来4小时,也就是16个时间点的功率曲线。这个时间尺度属于短时预测,比超短期(分钟级)稍微宽松一点,但比中长期(天级)难很多,因为天气系统在几小时内随时可能变化。
1.2 为什么短期功率预测比很多人想象中难
很多人第一次做光伏预测会觉得“不就是把历史功率数据丢进模型吗”,但实际上完全不是这样。光伏功率序列有几个非常麻烦的特性。
第一是强非平稳性。白天有功率、晚上基本都是零,一年四季日照时长和角度都在变,模型必须学会区分昼夜、区分季节。第二是突变性。晴空条件下功率曲线很光滑,一旦有云层遮挡,功率可能在几分钟内剧烈波动。第三是强耦合性。功率本身不是独立生成的,而是辐照度、温度、湿度、风速等多种气象因素共同作用的结果。如果模型只输入历史功率而不输入气象特征,那它本质上是在“盲猜天气”,预测效果必然有限。
所以这个项目从一开始就没有走“纯时间序列”路线,而是把历史功率和气象预报数据都作为模型输入。这也是后面所有模型设计的共同基础。
1.3 预测任务的技术定义
先把任务定义清楚,后面所有代码都好写。
假设历史数据每15分钟采集一次,一天就是96个点。模型的输入是过去一段时间的观测序列,包括历史功率、辐照度、温度、湿度、风速等,输出是未来16个时间点的功率值。
输入窗口长度我试过48、72、96、120,最终定在96,也就是过去一天的数据。这个长度对捕捉日周期性很有帮助——模型能看到昨天同一时刻在发多少电,这比只看过去三小时要稳得多。输出步长16步,对应未来4小时。
评价指标上,除了MAE、RMSE这种常规误差,我还重点看按装机容量归一化后的误差百分比。比如说一个10MW的电站,RMSE如果是0.35MW,那归一化误差就是3.5%。这个指标可以跨电站对比,行业里也常用。
1.4 为什么我选了这四种模型做对比
选模型不是拍脑袋,而是有针对性的。LSTM是时序预测的经典基线;TCN用膨胀因果卷积处理长序列,训练速度快;CNN-LSTM用卷积提取局部特征再送进LSTM,兼顾空间特征和时间依赖;TCN-LSTM则是把TCN的顶层输出接LSTM,想验证一下“强特征提取器+循环记忆单元”能不能产生一加一大于二的效果。
这四个模型恰好覆盖了时间序列预测的几条技术路线:纯循环结构、纯卷积结构、卷积+循环并联/串行、卷积+循环深度融合。做完对比之后,我对不同结构的优劣势有了非常直观的认识,后面会详细展开。
2. 数据获取与预处理:预测效果的上限在这里
2.1 数据来源与字段说明
项目使用的原始数据分为两部分:一部分是电站SCADA系统记录的实际发电功率,另一部分是气象监测站采集的环境数据,同时结合了气象预报数据作为模型输入。
我把字段整理成了下面这张表,方便理解:
| 字段 | 含义 | 分辨率 | 备注 |
|---|---|---|---|
| power | 实际发电功率(MW) | 15分钟 | 模型预测目标 |
| ghi | 水平面总辐照度(W/m²) | 15分钟 | 最重要特征 |
| dhi | 散射辐照度(W/m²) | 15分钟 | 阴天时影响大 |
| temp | 环境温度(℃) | 15分钟 | 影响组件效率 |
| humidity | 相对湿度(%) | 15分钟 | 与云层相关 |
| wind_speed | 风速(m/s) | 15分钟 | 影响组件散热 |
| pressure | 气压(hPa) | 15分钟 | 可辅助判断天气系统 |
辐照度这个特征几乎决定了光伏功率的上限。在晴空条件下,功率曲线和辐照度曲线高度一致;多云天气下,两者虽然波动但趋势也基本同步。所以对光伏预测来说,辐照度特征的准确性比模型的复杂程度更关键。
2.2 数据清洗的三个关键步骤
原始数据不能直接用,我踩了不少坑,清洗主要做了三件事。
第一,处理夜间数据。光伏电站晚上功率为0,但辐照度数值在夜间也基本为0,模型学习时这些点会占据大量样本。我保留夜间数据但给样本设置了一个规则:如果预测时段内最高辐照度低于50W/m²,这个样本直接丢弃。因为晚上预测0没有意义,白白增加训练负担。
第二,异常值剔除。功率值不可能为负,也不可能超过装机容量加合理过载值。我把功率小于0的点和超过12MW的点全部标记为异常。另外辐照度出现跳变超过合理范围的,比如半小时内从800掉到0又弹回800,基本是传感器故障,直接剔除。
第三,缺失值处理。光伏电站数据偶尔会有几分钟采集失败,形成空值。我用的策略是:单点缺失用前后线性插值,连续缺失超过3个点(即45分钟)的,用前一天同时刻的值填充后再做平滑。这里要特别注意:不要用整条序列的平均值去填,那样会把突变信息全部抹掉,模型学不到真实的波动状态。
2.3 特征工程的几点细节
清洗完数据之后,特征工程会直接影响模型上限。我最后用的特征集包括:
- 历史功率序列,这是最基础的信息。
- 历史气象观测值,包括辐照度、温度、湿度、风速、气压。
- 未来时段的辐照度预报值,这部分来自气象预报数据。
- 时间编码,包括小时、星期、月份,用正弦余弦编码转成周期性特征。
关于时间编码多说两句。光伏功率有明确的日周期和季节周期,直接用数值表示“小时=15”会让模型以为15和16的差别是1,但实际上23和0之间的差别应该很小。我用正弦余弦把时间编码成二维向量,比如小时特征变成sin(2πh/24)和cos(2πh/24),这样模型才能正确理解时间的周期性。
滞后特征也很重要。除了当前时刻的辐照度,我还加入了15分钟前、30分钟前、60分钟前的辐照度变化量。云层移动导致的辐照度变化趋势,通过这种差分特征能够被模型捕捉到。
2.4 数据集划分与归一化:最容易犯错的地方
训练集、验证集、测试集绝对不能随机打乱,这一点我特别想强调。时间序列数据一旦打乱,相当于把未来的信息泄漏到训练过程里,模型指标会虚高,上线后立刻打回原形。
我的划分方式是按时间顺序:前10个月做训练集,接下来1个月做验证集,最后1个月做测试集。这里可能有人会问:不同季节的数据差异很大,测试集只用冬天会不会不公平?我的处理是验证和测试数据覆盖了完整季节周期,每个集合里都包含晴天、多云、阴天、雨天样本,保证评估结果有代表性。
归一化我用了MinMaxScaler,把每个特征缩放到[0,1]区间。关键操作是先只在训练集上拟合scaler的参数,然后用这个scaler去变换验证集和测试集。如果直接用全部数据的min和max去归一化,测试集的信息就已经被模型“看到”了,这属于隐式数据泄漏。
2.5 滑动窗口怎么构造样本
模型需要的不是单条数据,而是“输入窗口+预测窗口”的样本对。
我用一个96×8的矩阵作为输入:96代表过去一天96个时间点,8代表8个特征维度。输出是16×1的向量,代表未来4小时的功率。生成样本时用一个长度为112的滑窗,在前96个点截取特征,在后16个点截取功率标签,然后滑动一个点继续。
这样算下来,一年约35040个原始时间点,剔除夜间样本后大约能生成14000多个有效样本。数据量不算巨大,但对训练中小型深度模型来说已经足够。
提示:构造样本时步长不要设置太大。如果每4个点才取一个样本,会导致相邻样本重复度过高,模型容易过拟合,而且验证效果失真。
3. 四个模型的设计与PyTorch实现
3.1 LSTM基线模型:循环结构的教科书实现
LSTM是长短期记忆网络,核心思想是用三个门(输入门、遗忘门、输出门)控制信息的保留和丢弃。我用了一个非常直白的类比来理解它:LSTM像一个带笔记本的人,读到新信息时先判断重不重要(输入门),把重要的记到本子上(细胞状态),把不重要的忘掉(遗忘门),最后根据本子的内容决定说什么话(输出门)。
光伏功率序列有很强的时序连续性,当前时刻功率和过去几小时高度相关,所以LSTM天然适合这种任务。但纯LSTM也有明显问题:它是逐步处理序列的,计算速度慢,而且如果输入序列太长,前面较早的信息容易被遗忘。
模型结构如下:
import torch import torch.nn as nn class LSTMModel(nn.Module): def __init__(self, input_size=8, hidden_size=64, num_layers=2, output_size=16, 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, seq_len, input_size) out, _ = self.lstm(x) # 取最后一个时间步的输出 out = out[:, -1, :] out = self.fc(out) return outhidden_size=64,两层LSTM,dropout=0.2防止过拟合。最后只取最后一个时间步的隐藏状态,通过全连接层直接输出16个预测值。
这里多说一句:为什么取最后一个时间步而不是所有时间步的平均池化?因为最后一个时间步的隐藏状态已经编码了整个输入序列的历史信息,对整个序列做平均反而会稀释临近时刻信息的重要性。光伏预测里,最近几个小时的功率比凌晨时段的功率对预测未来影响更大。
3.2 TCN模型:感受野与因果卷积
TCN全称Temporal Convolutional Network,时间卷积网络。它不是简单的一维卷积,而是用膨胀卷积(dilated convolution)和因果卷积(causal convolution)组合出很大的感受野。
膨胀卷积的原理可以这样理解:普通卷积看一个局部窗口,膨胀卷积每隔d个点取一个值,d就是膨胀率。d=1时就是普通卷积,d=2时跳过1个点,d=4时跳过3个点。通过多层堆叠,每一层的感受野都在指数增长,模型能用很少的层数看到很长的历史。
因果卷积则保证t时刻的输出只依赖t时刻及之前的信息,不会用到未来数据。这一点对时间序列预测至关重要——在预测时你根本没有未来的数据可用。
我用的TCN结构是三层膨胀卷积,膨胀率分别是1、2、4,每层卷积核大小为3。感受野计算公式是:
感受野 = 1 + (kernel_size - 1) × Σ(dilation)
代入数值:1 + (3-1) × (1+2+4) = 15。也就是说三层TCN只能看到过去15个点(约3.75小时),这对我96步的输入窗口来说太短了。所以我加深了网络,用5层,膨胀率1、2、4、8、16,感受野变成1 + 2×(1+2+4+8+16) = 63,然后再加一层膨胀率为32的卷积,感受野达到127,覆盖了96个输入点。
class TCNBlock(nn.Module): def __init__(self, in_channels, out_channels, kernel_size=3, dilation=1, dropout=0.2): super().__init__() self.padding = (kernel_size - 1) * dilation self.conv1 = nn.Conv1d(in_channels, out_channels, kernel_size, dilation=dilation, padding=self.padding) self.bn1 = nn.BatchNorm1d(out_channels) self.relu = nn.ReLU() self.dropout = nn.Dropout(dropout) def forward(self, x): out = self.conv1(x) out = out[:, :, :-self.padding] if self.padding > 0 else out out = self.bn1(out) out = self.relu(out) out = self.dropout(out) return out class TCNModel(nn.Module): def __init__(self, input_size=8, hidden_size=64, output_size=16): super().__init__() self.conv0 = nn.Conv1d(input_size, hidden_size, kernel_size=1) self.block1 = TCNBlock(hidden_size, hidden_size, dilation=1) self.block2 = TCNBlock(hidden_size, hidden_size, dilation=2) self.block3 = TCNBlock(hidden_size, hidden_size, dilation=4) self.block4 = TCNBlock(hidden_size, hidden_size, dilation=8) self.block5 = TCNBlock(hidden_size, hidden_size, dilation=16) self.block6 = TCNBlock(hidden_size, hidden_size, dilation=32) self.fc = nn.Linear(hidden_size, output_size) def forward(self, x): # x: (batch, seq_len, features) -> (batch, features, seq_len) x = x.transpose(1, 2) out = self.conv0(x) out = self.block1(out) out = self.block2(out) out = self.block3(out) out = self.block4(out) out = self.block5(out) out = self.block6(out) # 取最后时间步 out = out[:, :, -1] out = self.fc(out) return out这里需要注意一个细节:因果卷积的padding要设置在左侧,不能是普通卷积那种两边等宽的padding。我在TCNBlock里先padding,然后裁掉右半部分,保证输出长度和输入一致。如果不做这一步,序列长度会逐层缩短,多层堆叠后根本没法对齐。
TCN训练比LSTM快很多,因为卷积可以并行计算,不需要像LSTM那样一步步顺序处理。在同样的数据上,TCN一个epoch的训练时间大概是LSTM的三分之一。
3.3 CNN-LSTM模型:局部特征提纯后再建模时序
CNN-LSTM的思路是先用一维卷积提取局部特征,再用LSTM对提纯后的特征序列建模。直观理解就是:先用放大镜看每个短时间段内的特征模式,比如云层边缘经过时的辐照度变化形态,然后再把这些形态连接起来理解整体趋势。
为什么这么做?因为原始特征维度高、信息有冗余,直接把96×8的原始数据喂给LSTM,模型要同时处理特征提取和时序建模两个任务,学习压力大。卷积层相当于预处理器,把每个时间点附近的局部模式压缩成更紧凑的表示,LSTM只负责时序建模,负担小得多。
结构上我用了两层Conv1d+ReLU+MaxPool1d做下采样,把序列长度从96压到24,通道数从8提升到32,再接LSTM层:
class CNNLSTMModel(nn.Module): def __init__(self, input_size=8, conv_channels=32, lstm_hidden=64, output_size=16, kernel_size=5): super().__init__() # 卷积部分 self.conv1 = nn.Conv1d(input_size, conv_channels, kernel_size, padding=kernel_size//2) self.relu = nn.ReLU() self.pool1 = nn.MaxPool1d(kernel_size=2, stride=2) # 96 -> 48 self.conv2 = nn.Conv1d(conv_channels, conv_channels*2, kernel_size, padding=kernel_size//2) self.pool2 = nn.MaxPool1d(kernel_size=2, stride=2) # 48 -> 24 # LSTM部分 self.lstm = nn.LSTM( input_size=conv_channels*2, hidden_size=lstm_hidden, num_layers=1, batch_first=True ) self.fc = nn.Linear(lstm_hidden, output_size) def forward(self, x): x = x.transpose(1, 2) # (batch, seq_len, features) -> (batch, features, seq_len) out = self.conv1(x) out = self.relu(out) out = self.pool1(out) out = self.conv2(out) out = self.relu(out) out = self.pool2(out) # (batch, channels*2, 24) out = out.transpose(1, 2) # (batch, 24, channels*2) out, _ = self.lstm(out) out = out[:, -1, :] out = self.fc(out) return outkernel_size=5是经验值。光伏功率在15分钟粒度下,云层遮挡造成的影响通常持续半小时到一小时,也就是2~4个采样点。卷积核覆盖5个点,刚好能捕捉到一次完整的功率跌落和回升过程。核太小抓不到完整模式,核太大又会引入过多无关信息,导致特征不够尖锐。
CNN-LSTM的三层能力分工很明确:第一层卷积找局部模式,第二层卷积在更大范围组合这些模式,LSTM负责把这些模式排序成时间依赖关系。效果上,它比纯LSTM有明显改进,尤其是在多云天气下,预测曲线比LSTM平滑很多,不会出现无意义的剧烈抖动。
3.4 TCN-LSTM模型:双向增强的混合架构
TCN-LSTM是我这个项目里最后设计的模型,也是最终效果最好的一款。它的想法很简单:TCN擅长提取多尺度局部特征,感受野大、计算快;LSTM擅长建模长距离依赖关系,对“记住”跨天同一时刻的模式很有效。两者融合,理论上能同时获得局部敏感性和长程记忆。
结构上是串行连接:输入先经过TCN的多层膨胀卷积,然后序列被压缩到更短的等价表示,再送入LSTM层,最后输出预测。
代码实现:
class TCNLSTMModel(nn.Module): def __init__(self, input_size=8, hidden_size=64, tcn_layers=4, lstm_hidden=64, output_size=16): super().__init__() self.conv0 = nn.Conv1d(input_size, hidden_size, kernel_size=1) tcn_blocks = [] dilations = [1, 2, 4, 8] for i, d in enumerate(dilations): tcn_blocks.append(TCNBlock(hidden_size, hidden_size, kernel_size=3, dilation=d)) self.tcn = nn.Sequential(*tcn_blocks) self.lstm = nn.LSTM(hidden_size, lstm_hidden, num_layers=1, batch_first=True) self.fc = nn.Linear(lstm_hidden, output_size) def forward(self, x): x = x.transpose(1, 2) out = self.conv0(x) out = self.tcn(out) # (batch, hidden, seq_len) out = out.transpose(1, 2) # (batch, seq_len, hidden) out, _ = self.lstm(out) out = out[:, -1, :] out = self.fc(out) return out为什么TCN-LSTM比单独的TCN好?我的理解是:TCN虽然感受野大,但它的输出对序列中不同位置的信息是“平等对待”的,最后一个卷积层综合了所有历史信息,但没有区分“哪些历史时段对预测未来更重要”。LSTM通过门控机制学会了动态加权,比如晴天时更看重最近几小时,阴天时更看重昨天同一时刻,这种动态选择是静态卷积网络做不到的。
为什么比单独的LSTM好?因为LSTM第一步就需要从原始连续特征里提取信息,噪声多、收敛慢。TCN先行提取出光滑、有意义的局部特征后,LSTM面对的输入质量高得多,学习难度大幅下降。训练时能明显感觉到:TCN-LSTM的验证集loss下降更平滑,震荡更少。
4. 训练配置与实验对比结果
4.1 训练策略:损失函数、优化器与早停
损失函数我用了MSE(均方误差)。虽然MSE对异常值敏感,但功率预测任务希望模型对大幅度偏差更警惕,因为对电网调度来说,预测偏差5%和偏差1%的代价完全不同。MSE的平方特性天然放大大误差的惩罚,符合业务诉求。
优化器用Adam,初始学习率0.001,batch size 64,训练最多100个epoch。同时做了两个关键操作:早停和学习率衰减。早停的patience设为10,即验证集loss连续10个epoch不下降就停止训练,防止过拟合。学习率在验证集loss进入平台期时降低为原来的0.1倍,让模型在最优解附近更精细地收敛。
梯度裁剪也很重要。LSTM类模型训练时很容易梯度爆炸,我在每个batch反向传播之后做了梯度裁剪,max_norm设为1.0。项目初期没加裁剪时,loss经常在某一步突然跳到nan,加了之后训练稳定了很多。
4.2 评价指标:不只看MAE,还要看归一化误差
单看一个指标容易被误导。我用四个指标综合评估:
| 指标 | 含义 | 本项目中的用途 |
|---|---|---|
| MAE | 平均绝对误差 | 反映平均预测偏差 |
| RMSE | 均方根误差 | 对大偏差更敏感 |
| MAPE | 平均绝对百分比误差 | 归一化比较 |
| R² | 决定系数 | 反映模型解释力 |
其中MAPE是相对装机容量的百分比误差,计算公式是abs(pred - actual) / capacity取平均。这个指标的优点是跨电站可比,行业里报业绩也常用它。
4.3 四种模型的实验对比结果
测试集上的结果如下(10MW电站,预测未来4小时,15分钟粒度):
| 模型 | MAE (MW) | RMSE (MW) | MAPE (%) | R² |
|---|---|---|---|---|
| LSTM | 0.283 | 0.412 | 5.61 | 0.903 |
| TCN | 0.241 | 0.356 | 4.72 | 0.926 |
| CNN-LSTM | 0.198 | 0.302 | 3.89 | 0.945 |
| TCN-LSTM | 0.162 | 0.257 | 3.24 | 0.961 |
从数据可以清楚看到:TCN-LSTM在四个指标上全面领先,MAE比纯LSTM低了43%,MAPE从5.61%降到3.24%,这是一个很可观的提升。CNN-LSTM排名第二,说明卷积做特征提取确实有效。纯TCN比纯LSTM好,验证了并行卷积结构的优势。
4.4 结果解读:为什么TCN-LSTM最好,纯LSTM的局限在哪
拆分来看,有几个结论值得记录。
纯LSTM上rmse和mae的差距最大(0.412 vs 0.283),说明它存在一些“误差集中爆发”的点,可能是某个突变天气样本完全预测失败。LSTM对长序列的遗忘问题是真实存在的,即使有门控机制,96步的输入长度也已经逼近它的记忆极限。
TCN的优势是训练快、感受野大,但它的缺陷在于没有“选择性记忆”。它把所有历史信息一视同仁地卷积进特征图,最后的输出是全局信息的加权平均。面对光伏这种“近因效应”极强的序列,这种方式显得笨重。
CNN-LSTM之所以不错,是因为卷积层相当于给数据做了一次“提纯”,把云层波动这种局部形态先识别出来,LSTM再做时序整合。但它的CNN部分用的是普通卷积,感受野覆盖范围有限,对跨小时的长程模式捕捉能力弱于TCN。
TCN-LSTM把两个优势叠起来了:TCN提供多尺度特征金字塔,LSTM提供动态记忆选择。实际预测曲线中最明显的变化是:晴天预测几乎完美贴合实际值,多云天气的预测波动比纯LSTM更平滑,连续阴雨天也不会出现整体偏移,而是能快速跟踪实际功率走向。
5. 核心代码落地与工程化经验
5.1 完整的预测Pipeline长什么样
模型实验只是第一步,落地部署才是真正麻烦的地方。我整理了项目里的完整流程,大致分四段:
- 数据层:从数据库读取历史功率和气象数据,做清洗、对齐、特征工程,生成滑动窗口样本。
- 训练层:划分训练验证集,标准化,训练模型,用早停保存最优模型权重。
- 推理层:加载模型和标准化参数,输入最近96个时间点的特征,输出未来16个点的功率预测。
- 存储层:把预测结果写回数据库,供展示页面和报表系统调用。
如果要做定时预测,一般每小时跑一次,每次预测未来4小时,重叠窗口可以平滑掉相邻预测之间的跳变。
5.2 多步预测:直接多步输出还是递归预测?
多步预测有三种常见做法:递归预测、直接多步输出、Seq2Seq结构。项目里我直接用了直接多步输出,也就是模型的输出层一次性给出16个时间点的预测值。
为什么不递归?递归预测是把模型单步预测的结果作为输入再预测下一步,误差会随时间步长累积,预测到第16步时已经偏离得很厉害。为什么不搞Seq2Seq?因为光伏预测的输入输出长度是固定的(96步进、16步出),用编码器-解码器结构没有明显收益,反而增加训练复杂度。直接多步输出的实现最简单,效果也不差,已经满足项目需求。
5.3 部署推理时最容易忽视的四个问题
第一,标准化参数必须持久化。训练时用的scaler对象只在内存里,服务重启后就没了。我在训练结束后把scaler的min和scale保存成json文件,推理时加载使用。这是最常见的部署坑,少了这一环节,模型输出完全不对,但很多人排查半天都找不到原因。
第二,输入数据的特征顺序必须和训练时完全一致。有一次我把特征顺序调整了一下,模型输出的结果直接乱了套。建议在模型输入前加一个特征顺序校验,或者把特征名也保存到配置文件里。
第三,推理速度要提前测。TCN-LSTM的参数量不大(约20万),单次推理在普通CPU上耗时约30毫秒,完全满足每小时一次的预测需求。但如果预测频率提高到分钟级,最好还是上一张GPU或者用ONNX做推理加速。
第四,预测结果的置信度要标注。我在系统里给每条预测曲线附加了一个置信区间,区间宽度由历史误差分布计算得出。调度人员看到的不只是一个点数,而是一个范围,这对实际决策帮助很大。
6. 踩坑记录与避坑指南
6.1 数据泄漏:归一化用了全量数据,测试集指标好看到不真实
项目早期我犯过一个非常隐蔽的错误:归一化时用了全部数据的max和min,包括测试集。结果测试集MAE比预期好了一大截,模型R²甚至到了0.98。当时还很开心,后来复盘发现测试集的信息已经参与了训练前的数据变换,属于典型的数据泄漏。
修正方式前面提过:只fit训练集的scaler,然后transform验证和测试集。这里要强调一点:这里的“训练集”必须是时间上最早的那段数据,不能把验证集混进来一起fit。否则验证集指标同样会失真。
6.2 晴转阴样本的处理:一个样本就能让RMSE暴涨
有一次我单独分析模型在具体日期的表现,发现某一天RMSE特别大。查了一下,那天下午三点从大晴天变成了强对流天气,功率在半小时内从9.2MW掉到1.1MW。模型预报的是晴空功率曲线,完全没跟上实际变化。
这类样本是短期预测的天然难点——没有任何模型能提前预知突发强对流云团。我的处理是:在评价指标里同时报告“全样本误差”和“滤除极端天气样本后的误差”。汇报时两个数字都提供,让业务方了解模型上限和“非战之罪”的下限。
6.3 夜间零值样本导致训练不平衡
项目初期模型在白天表现很好,但夜间偶尔输出2MW的功率,非常离谱。后来分析发现,夜间样本占比约30%,但如果完全丢弃夜间样本,模型没见过“输入全为零输出全为零”的模式,夜间推理时行为不可控。
我最后采用了“保留10%夜间样本”的策略。具体操作是把夜间样本单独拿出来,随机抽取10%加入训练集,其余丢弃。这样模型既能学到“夜间输出应该为0”的模式,又不会被大量无信息样本拖慢训练。
6.4 让我最痛苦的一个问题:验证集loss下降但预测曲线整体滞后
我做实验时遇上过一个特别诡异的现象:loss一直在降,但预测曲线总是比实际功率曲线滞后15~30分钟。也就是说预测的是“过去”而不是“未来”。
这个问题花了我差不多两天才找到根因。问题不在模型结构,而在预测目标的构造方式。当时我把预测目标shift错了:模型的第k个输出对应的是输入窗口内第16+k个点,而不是未来第k个点。相当于模型在预测“延迟了15分钟的历史”。
排查方法非常笨但有效:我在验证集上随机挑了一个晴天样本,把预测值和实际值画在同一张图里,肉眼一看就发现两条曲线错位了一个相位。如果你也遇到loss下降但曲线滞后的问题,优先检查标签shift的逻辑,不要先怀疑模型结构。
6.5 实用避坑清单
- 训练时保存最优模型一般以验证集MAPE为准,不要只看训练集的loss,不然模型永远在“背答案”。
- 预测结果曲线要用平滑滤波处理后展示,但不能用未来数据做平滑。实测用过去三个点的滑动平均效果最好,不会产生时序泄漏。
- 若预测步长较长(超过8步),建议输出时给每步单独配一个全连接头,而不是所有步共用同一个线性层,短步预测和长步预测的复杂度不同。
- 多模型融合有潜力但项目周期会长很多。如果时间紧张,TCN-LSTM单模型配合好的数据预处理,已经能跑出不错的业务效果。
- 模型版本必须和特征版本绑定记录。有一次我改了特征工程但忘记同步模型版本号,线上模型实际用的是旧特征配置,结果出问题后排查了很久。
写在最后:一点个人体会
做完这个项目,我最大的体会是:在光伏功率预测这个场景里,模型之间的差异远小于数据质量的差异。数据清洗和特征工程花的时间,至少是模型调参的三到四倍。TCN-LSTM这套组合确实是目前实践下来效果最稳定的方案,但前提是你得把辐照度、温度这些特征理顺,把异常数据清干净,把窗口长度选合适。模型结构只是把你对业务的理解表达出来,真正的功夫都在数据上。最后再说一个小建议:如果你打算做类似的时间序列预测项目,先花两天把数据完整可视化一遍。画出功率曲线、辐照度曲线,用眼睛看过数据之后再动手建模,你对特征重要性的理解会比直接跑模型深刻得多。