AI 赋能光伏发电量预测:从特征工程到 LSTM/XGboost 双基线实战
- [AI 赋能光伏发电量预测:从特征工程到 LSTM/XGboost 双基线实战](#AI 赋能光伏发电量预测:从特征工程到 LSTM/XGboost 双基线实战)
- feat_engine.py
-
- [四、Baseline ①:XGBoost](#四、Baseline ①:XGBoost)
-
- [4.1 XGBoost 凭什么成为生产首选](#4.1 XGBoost 凭什么成为生产首选)
- train_xgb.py
-
- [五、Baseline ②:PyTorch LSTM](#五、Baseline ②:PyTorch LSTM)
-
- [5.1 原理先行:为什么 LSTM 比 XGBoost"吃"得起长序列](#5.1 原理先行:为什么 LSTM 比 XGBoost"吃"得起长序列)
- [5.2 第一步:标准化必须"训练集隔离"](#5.2 第一步:标准化必须"训练集隔离")
- normalize_split.py
-
-
- [5.3 第二步:Dataset 必须返回"最后时刻对齐"](#5.3 第二步:Dataset 必须返回"最后时刻对齐")
-
- dataset_pv.py
-
-
- [5.4 第三步:模型定义(取最后时刻 + 梯度裁剪)](#5.4 第三步:模型定义(取最后时刻 + 梯度裁剪))
-
- model.py
-
-
- [5.5 第四步:完整训练循环(含 EarlyStopping + 设备迁移)](#5.5 第四步:完整训练循环(含 EarlyStopping + 设备迁移))
-
- train.py
-
-
- [5.6 第五步:反归一化后算指标(别在归一化空间里报数)](#5.6 第五步:反归一化后算指标(别在归一化空间里报数))
-
- evaluate.py
AI 赋能光伏发电量预测:从特征工程到 LSTM/XGboost 双基线实战
在"双碳"目标驱动下,光伏发电装机容量连年攀升,已成为新型电力系统的主力电源之一。然而,光伏出力受辐照度、温度、云层运动等气象因素强耦合影响,具有显著的间歇性、波动性和不确定性------这种"靠天吃饭"的特性给电网调度、电力市场交易和电站运维带来了巨大挑战。
准确的光伏发电量预测是解决这一矛盾的核心抓手。传统的物理建模方法依赖高精度的组件参数和场地信息,在复杂天气下泛化能力有限;而统计方法(如 ARIMA)又难以捕捉辐照度与功率之间的强非线性关系。近年来,随着 AI 技术的成熟,机器学习(XGBoost)和深度学习(LSTM)凭借强大的特征拟合能力和端到端建模优势,在光伏功率预测领域展现出远超传统方法的性能。
但"把模型跑起来"和"把预测系统真正落地"之间,隔着无数工程细节的坑:时序数据泄漏、标准化顺序错误、夜间样本处理不当、滑窗维度错位......这些问题不会在教程里出现,却会在你上线时一个个跳出来。
本文基于实际光伏电站时序数据,从物理特征工程 → 时序安全切分 → XGBoost/LSTM 双基线实现 → 评估指标规范 → 工程部署避坑五个维度,给出一套可直接复现、可上线部署的完整方案。无论你是做课程设计、写论文实验,还是真要往生产环境推,这套 pipeline 都能帮你少走至少两周弯路。
一、为什么要单独做一套光伏专用的预测 pipeline
通用模型通常假设数据是平稳、单一变量主导、边界开放的。但光伏功率存在三个特殊性质:

光伏功率不是"历史功率的简单延续",而是"天气驱动的带惯性动态系统"。这正是本文主线 物理特征构造 → 时序切分防泄漏 → 双模型对照 的设计依据。
预测目标的四种尺度
不同时间尺度对应的技术路线完全不同,在写论文或做需求评审时需要先对齐尺度。具体分为四类:一是超短期(0--4h),主要用于电站滚动调度和逆变器控制,主流方案是卫星云图结合时序模型;二是短期(1--3d),面向日前市场出清和购电计划,通常采用数值天气预报配合 LSTM 或 XGBoost;三是中期(周/月),服务于检修排程和燃料备用,以统计模型为主;四是长期(年),用于容量规划和收益评估,一般基于典型年数据叠加衰减模型。本文聚焦超短期到短期这一最常用区间,因此采用小时级数据加未来 1h 单步预测。
二、数据集怎么选
2.1 数据源对比
第一个坑:TMY(典型气象年)是合成年,只适合设计仿真;训练预测模型必须用完整时序真实数据。
常用的公开数据源有以下三个。首选是 NSRDB (NREL PSM),分辨率为 4km 或 30 分钟,覆盖北美地区,GHI 偏差在 ±5% 以内;如果项目位于欧洲、非洲或亚洲,可以使用 PVGIS SARAH-3,分辨率为 250m 或 1 小时,适合作为国内项目的备选;当缺少具体站点数据时,则用 NASA POWER 进行全球初筛,它的分辨率为 0.5° 或 1 小时,作为兜底方案使用。
2.2 数据字段的"因果层级"
理解字段之间的因果关系,能帮你判断哪些特征该用、哪些会泄漏:
第一层(气象驱动):GHI、云量、温度、风速------因果输入,可放心使用
第二层(组件响应):背板温度、组件温度------由辐照和气温共同决定,可用但注意时间对齐
第三层(系统输出):逆变器功率、并网点功率------目标量本身,只能取历史滞后值
原则:任何字段若包含了"当前时刻"的目标信息,就只能作为历史滞后特征,不能作为当前时刻的输入------这是防泄漏的第一道关口。
三、特征工程
3.1 为什么需要专门的特征工程
模型不是"越复杂越好",而是"喂进去的信息越有用越好"。在光伏预测中,原始数据里有大量周期性信号被埋在绝对数值里(例如"一天中太阳所在的位置"),需要显式编码才能让模型用得上。
核心思路是三层构造:
周期性编码:把"时刻"转成 sin/cos,消除 23→0 的跳变
物理派生:把专家知识写成公式,降低模型学习难度
历史滞后:把功率的惯性显式暴露给模型
feat_engine.py
python
import numpy as np, pandas as pd
from sklearn.preprocessing import StandardScaler
def pv_feature_pipeline(df: pd.DataFrame) -> pd.DataFrame:
"""
输入 df 至少含: timestamp, ghi(W/m2), temp_c, wind,
cloud_total, power_kw(目标)
"""
df = df.copy()
df["ts"] = pd.to_datetime(df["timestamp"])
df = df.sort_values("ts").set_index("ts")
# --- 天文周期编码(sin/cos 避免 23->0 跳变)---
minutes = (df.index.hour * 60 + df.index.minute) / (24 * 60)
df["hour_sin"] = np.sin(2 * np.pi * minutes)
df["hour_cos"] = np.cos(2 * np.pi * minutes)
doy = df.index.dayofyear / 365.0
df["doy_sin"] = np.sin(2 * np.pi * doy)
# --- 物理派生特征 ---
df["clear_sky_idx"] = df["ghi"] / 1000.0 # CSI
df["temp_penalty"] = 1.0 - 0.004 * (df["temp_c"] - 25) # 温度系数
df["wind_cool"] = df["wind"] * (df["temp_c"] - 25).clip(lower=0)
# --- 滞后与目标滑窗(只看历史)---
df["lag_power_1h"] = df["power_kw"].shift(1)
df["lag_power_24h"] = df["power_kw"].shift(24)
df["roll_power_7d_same_hour"] = (
df["power_kw"].groupby(df.index.hour).transform(
lambda s: s.shift(1).rolling(7, min_periods=3).mean())
)
# ★ 夜间 mask:按辐照度不用固定钟点
df = df[df["ghi"] >= 20].copy()
return df.dropna()
FEATURE_COLS = [
"ghi","temp_c","wind","cloud_total",
"hour_sin","hour_cos","doy_sin",
"clear_sky_idx","temp_penalty","wind_cool",
"lag_power_1h","lag_power_24h","roll_power_7d_same_hour"
]
💡 为什么用 sin/cos 而不是 one-hot:one-hot 会把"23 点"和"0 点"当成两个毫无关系的类别,而它们实际相邻;sin/cos 编码天然保留了"圆"上的距离关系,模型更容易学出昼夜规律。
四、Baseline ①:XGBoost
4.1 XGBoost 凭什么成为生产首选
XGBoost 在这类任务上的优势不在精度第一,而在性价比最高:训练速度快,分钟级即可完成,适合每日滚动重训;可解释性强,feature_importances_ 可直接看出哪些气象因子起作用,便于运维审计;鲁棒性强,对异常值、缺失值容忍度高,无需复杂归一化;部署轻量,单文件模型,CPU 即可推理。
train_xgb.py
python
```python
import xgboost as xgb
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_absolute_error, r2_score
def train_baseline(X, y):
# ★ 时序严格顺序切,绝不 shuffle
split = int(len(X) * 0.8)
X_tr, X_te, y_tr, y_te = X[:split], X[split:], y[:split], y[split:]
model = xgb.XGBRegressor(
objective="reg:squarederror",
n_estimators=500, learning_rate=0.05,
max_depth=6, colsample_bytree=0.8,
subsample=0.9, random_state=42, n_jobs=-1,
)
model.fit(X_tr, y_tr,
eval_set=[(X_te, y_te)], verbose=False)
pred = model.predict(X_te)
print(f"MAE={mean_absolute_error(y_te,pred):.2f} kW "
f"R²={r2_score(y_te,pred):.4f}")
return model
实测 10MW 场站水平:XGBoost + 异常处理 MAE≈76kW / MAPE 8.3%,比裸随机森林稳一档。
五、Baseline ②:PyTorch LSTM
5.1 原理先行:为什么 LSTM 比 XGBoost"吃"得起长序列
XGBoost 拿到的是同一时刻的横切面(当前辐照 + 温度 + 滞后特征),它本质上是一个强非线性映射器;而 LSTM 的输入是一段连续窗口 (seq_len, n_features),靠门控结构在序列内部做信息筛选:
遗忘门 f t = σ ( W f ⋅ h t − 1 , x t + b f ) f_t = \sigma(W_f\cdoth_{t-1},x_t+b_f) ft=σ(Wf⋅ht−1,xt+bf):决定上一刻状态里哪些"记忆"要丢掉(比如 3 小时前的阴天状态在转晴后该被遗忘)
输入门 i t i_t it:决定当前输入有多少写进细胞状态
输出门 o t o_t ot:决定当前输出多少
对光伏来说这个机制对口的场景是 云层遮挡的"记忆效应"------辐照度曲线在云通过时会出现秒级毛刺,但功率侧因为逆变器时间常数 + 组件热惯性会被平滑。LSTM 能在序列内学到这个"辐照毛刺 vs 功率真实响应"的滞后关系,而单点模型做不到。
⚠️ 反过来说:如果你的数据只有 3 个月的干净天气,LSTM 大概率打不过 XGBoost------门控带来的参数量成了负担。
5.2 第一步:标准化必须"训练集隔离"
normalize_split.py
python
"""
★ 核心原则:scaler 只 fit 训练集,验证/测试集只用 .transform
先滑窗再 fit 是错误的------滑窗会把未来的样本带入统计
"""
import numpy as np
from sklearn.preprocessing import StandardScaler, MinMaxScaler
class SafeScaler:
"""统一封装特征/标签两套 scaler,避免训练集外泄"""
def __init__(self, feature_scaler=StandardScaler(),
target_scaler=MinMaxScaler()):
self.fs = feature_scaler
self.ys = target_scaler
self.fitted = False
def fit(self, X_train, y_train):
self.fs.fit(X_train)
self.ys.fit(y_train.reshape(-1, 1))
self.fitted = True
def transform(self, X, y=None):
assert self.fitted, "必须先调用 fit()"
Xs = self.fs.transform(X)
if y is None:
return Xs
ys = self.ys.transform(y.reshape(-1, 1)).ravel()
return Xs, ys
def inverse_target(self, y_scaled):
"""★ 预测结果必须反归一化后才能算 MAE/RMSE"""
return self.ys.inverse_transform(
np.asarray(y_scaled).reshape(-1, 1)).ravel()
def make_windows(X_scaled, y_scaled, seq_len=96, horizon=1):
"""
滚动滑窗构造监督学习样本
X_scaled : (N, F) 已标准化的全量特征
y_scaled : (N,) 已标准化的全量标签
返回 X:(M, seq_len, F) y:(M,) ------ 预测的是窗口【之后第 horizon 步】
"""
X, Y = [], []
for i in range(len(X_scaled) - seq_len - horizon + 1):
X.append(X_scaled[i:i + seq_len])
Y.append(y_scaled[i + seq_len + horizon - 1])
return np.array(X, dtype=np.float32), np.array(Y, dtype=np.float32)
为什么"先切分再 fit 再滑窗"是这个顺序:想象你按 8:1:1 切完得到三段索引,如果在三段合并的全量上 fit scaler,那么训练集算均值时已经"偷看"了 10 月的真实辐照分布,而这个分布验证集根本不该暴露------线上你同样拿不到未来数据。正确链条是:原始时序 → 按时间切三段 → 只在 train 上 fit scaler → transform 三段 → 分别滑窗。
5.3 第二步:Dataset 必须返回"最后时刻对齐"
dataset_pv.py
python
import torch
from torch.utils.data import Dataset, DataLoader
class PVSeqDataset(Dataset):
"""
返回单条样本: (x, y)
x: (seq_len, F) ------ 注意是 (seq_len, F) 不是 (F, seq_len)
y: scalar
PyTorch 的 batch_first LSTM 期望输入为 (B, L, F),DataLoader 会自动 stack
"""
def __init__(self, X, y):
self.X = torch.as_tensor(X, dtype=torch.float32)
self.y = torch.as_tensor(y, dtype=torch.float32)
def __len__(self):
return self.X.shape[0]
def __getitem__(self, idx):
return self.X[idx], self.y[idx]
def build_loaders(X_tr, y_tr, X_va, y_va, batch=64, num_workers=0):
"""
★ shuffle=False 是硬要求,DataLoader 不能打乱时序样本
★ drop_last=True 让最后一块不满 batch 的样本不参与,梯度更稳定
"""
tr_loader = DataLoader(
PVSeqDataset(X_tr, y_tr), batch_size=batch,
shuffle=False, drop_last=True, num_workers=num_workers)
va_loader = DataLoader(
PVSeqDataset(X_va, y_va), batch_size=batch,
shuffle=False, drop_last=False, num_workers=num_workers)
return tr_loader, va_loader
这里最容易写错的 shape:nn.LSTM(input_size=F, batch_first=True) 时,输入必须 (B, L, F)。很多人把特征维度放第一维 (F, L),跑起来不报错(因为 LSTM 会自动转置),但 batch 维度错位,训练出来的权重其实是垃圾。一个快速自查:next(iter(loader))0.shape 打印出来第一维必须是 batch_size、第三维必须是 n_features。
5.4 第三步:模型定义(取最后时刻 + 梯度裁剪)
model.py
python
import torch.nn as nn
class PVLSTM(nn.Module):
def __init__(self, n_features, hidden=64, n_layers=2, dropout=0.2):
super().__init__()
self.lstm = nn.LSTM(
input_size=n_features,
hidden_size=hidden,
num_layers=n_layers,
batch_first=True,
dropout=dropout if n_layers > 1 else 0)
self.norm = nn.LayerNorm(hidden) # ★ 稳定训练,允许更大学习率
self.head = nn.Linear(hidden, 1)
def forward(self, x):
# x: (B, L, F)
out, _ = self.lstm(x) # out: (B, L, H)
last = out[:, -1, :] # ★ Seq2One:只用窗口最后一刻
last = self.norm(last)
return self.head(last).squeeze(-1) # (B,)
为什么用 out:, -1, : 而不是 h_n-1:两者数值几乎等价,但前者在 batch_first=True 下无需关心 h_n 的 (num_layers, B, H) 布局,调试时直接 out.shape 就能看。另外 clip_grad_norm_ 是必选项------LSTM 梯度在 100+ 步长时会剧烈波动,不加裁剪 loss 会 nan 跳变。
5.5 第四步:完整训练循环(含 EarlyStopping + 设备迁移)
train.py
python
```python
```python
import torch, torch.nn as nn
from torch.optim import Adam
import numpy as np
def train_lstm(model, tr_loader, va_loader, epochs=100,
lr=1e-3, patience=12, device="cuda"):
device = torch.device(device if torch.cuda.is_available() else "cpu")
model = model.to(device)
opt = Adam(model.parameters(), lr=lr, weight_decay=1e-5)
scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(
opt, mode="min", factor=0.5, patience=5, verbose=True)
loss_fn = nn.MSELoss()
best_va, bad_epochs, best_state = float("inf"), 0, None
for ep in range(epochs):
# ---- train ----
model.train()
tr_loss, n = 0.0, 0
for xb, yb in tr_loader:
xb, yb = xb.to(device), yb.to(device) # ★ CPU tensor 必须显式迁移
opt.zero_grad()
pred = model(xb)
loss = loss_fn(pred, yb)
loss.backward()
nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) # ★
opt.step()
tr_loss += loss.item() * xb.size(0)
n += xb.size(0)
# ---- validate ----
model.eval()
va_loss, m, k = 0.0, 0, 0
with torch.no_grad(): # ★ 关闭 dropout/batchnorm 更新
for xb, yb in va_loader:
xb, yb = xb.to(device), yb.to(device)
pred = model(xb)
va_loss += loss_fn(pred, yb).item() * xb.size(0)
m += (pred - yb).abs().sum().item()
k += xb.size(0)
va_loss /= max(k, 1)
print(f"ep{ep:03d} | tr={tr_loss/n:.5f} va={va_loss:.5f} "
f"MAE={m/k:.3f} lr={opt.param_groups[0]['lr']:.1e}")
scheduler.step(va_loss)
# ---- early stopping ----
if va_loss < best_va - 1e-6:
best_va, bad_epochs = va_loss, 0
best_state = {k: v.detach().cpu().clone() # ★ 存 CPU 副本
for k, v in model.state_dict().items()}
else:
bad_epochs += 1
if bad_epochs >= patience:
print(f"Early stop @ ep{ep} best_va={best_va:.5f}")
break
model.load_state_dict(best_state)
return model
def predict(model, X_te, batch=256, device="cpu"):
"""★ 推理模式:关闭梯度 + eval(),否则结果不可复现"""
model.eval().to(device)
all_pred = []
with torch.no_grad():
ds = PVSeqDataset(X_te, np.zeros(len(X_te)))
loader = torch.utils.data.DataLoader(ds, batch_size=batch)
for xb, _ in loader:
all_pred.append(model(xb.to(device)).cpu().numpy())
return np.concatenate(all_pred)
训练循环中有四个"必须"注意的易错点,缺一个都会埋雷。第一是 xb.to(device),必须确保每个 batch 都显式迁移到目标设备,否则 CPU 和 GPU 张量混用会直接报错;第二是 torch.no_grad(),验证和推理阶段都必须用这个上下文管理器包起来,否则会导致显存暴涨,同时 dropout 等随机操作会引起结果抖动;第三是 clip_grad_norm_,建议设置 max_norm=1.0,如果不加这个梯度裁剪,loss 偶尔会出现 nan 跳变,让人白白花半天时间调学习率;第四是 best_state 存 CPU 副本,保存最佳模型权重时必须用 .detach().cpu().clone() 存为 CPU 张量,否则在多卡训练或后续复现时会出现设备不匹配的报错。
5.6 第五步:反归一化后算指标(别在归一化空间里报数)
evaluate.py
python
import numpy as np
def pv_metrics(y_true, y_pred, pmax, pmin):
"""
全部在【物理量】空间计算,y_true/y_pred 单位 kW
pmax, pmin: 训练集功率的上下界,用于 nRMSE
"""
y_true, y_pred = np.asarray(y_true), np.asarray(y_pred)
mae = np.mean(np.abs(y_true - y_pred))
rmse = np.sqrt(np.mean((y_true - y_pred) ** 2))
nrmse = rmse / (pmax - pmin) * 100 # 跨场站可比
mape = np.mean(np.abs((y_true - y_pred) /
np.clip(y_true, 1e-3, None))) * 100
bias = np.mean(y_pred - y_true)
return dict(MAE=round(mae, 2), RMSE=round(rmse, 2),
nRMSE=f"{nrmse:.2f}%", MAPE=f"{mape:.2f}%",
bias=round(bias, 2))
📐 nRMSE 的分母用 pmax−pmin 而不是均值------这样不同容量、不同地区的场站预测结果才具备可比性。论文和项目评审中统一口径很重要。
六、上线前必查的三个翻车点
这三处是其他研究里 90% 没提的,它们不写在代码里,而是写在流程规范里。
第一,DataLoader(shuffle=True) 直接废掉整个实验------时序样本混洗等于把未来天气喂给训练,必须保持 shuffle=False。第二,夜间别用 hour<6 or hour>18 截------冬夏日出差两小时,固定钟点会留下弱光脏样本,模型学会"躺平";正确做法是用 ghi ≥ 20 硬 mask。第三,MinMaxScaler 对全量 fit = 数据泄漏------只拿训练集统计,验证/测试集 transform 即可,否则离线好看上线翻倍。
还有三个隐蔽风险值得注意:一是设备更换或故障导致功率突变,模型会当异常学习进去,需配合 SCADA 事件打标过滤;二是组件每年约 0.5% 的衰减会带来 concept drift,模型需按月滚动重训;三是辐照传感器积灰会造成系统性偏低,需定期校准或改用卫星辐照作兜底。