AI 赋能光伏发电量预测:从特征工程到 LSTM/XGboost 双基线实战

AI 赋能光伏发电量预测:从特征工程到 LSTM/XGboost 双基线实战

  • [AI 赋能光伏发电量预测:从特征工程到 LSTM/XGboost 双基线实战](#AI 赋能光伏发电量预测:从特征工程到 LSTM/XGboost 双基线实战)
    • [一、为什么要单独做一套光伏专用的预测 pipeline](#一、为什么要单独做一套光伏专用的预测 pipeline)
    • 二、数据集怎么选
      • [2.1 数据源对比](#2.1 数据源对比)
      • [2.2 数据字段的"因果层级"](#2.2 数据字段的"因果层级")
    • 三、特征工程
      • [3.1 为什么需要专门的特征工程](#3.1 为什么需要专门的特征工程)
  • 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,模型需按月滚动重训;三是辐照传感器积灰会造成系统性偏低,需定期校准或改用卫星辐照作兜底。

相关推荐
迪飞特科技1 小时前
大模型接入业务系统:API、本地部署、RAG、智能体四种路线怎么选
java·数据库·人工智能·知识图谱·deepseek·本地化大模型
狂奔solar1 小时前
Harness²:Google 如何让 AI 自己“调教“AI——递归代理优化框架深度拆解
人工智能
viperrrrrrrrrr71 小时前
SFT 微调指南
人工智能
海姐软件测试1 小时前
Python 自动化与 AI 结合:基于 Jenkins 的回归验证实践
人工智能·python·自动化
回眸&啤酒鸭1 小时前
【回眸】纳米 AI 搜索效果实测与能力展示
人工智能
Haoyunyi1 小时前
2026年做期货量化,和三年前已经不是一回事了
大数据·人工智能·python·金融·区块链
通信瓦工1 小时前
Meta ORv3 HPR Next 机架与供电方案map
人工智能
shaibdoio1 小时前
两项计算机软件著作权背后:AI获客系统的技术自研路径
人工智能·长沙瞬维ai
紧跟先前的步伐1 小时前
Go AI Agent 实战 04|Prompt 与消息角色:System、User、Assistant
人工智能·golang·prompt