R复现GEFCOM2014负荷概率预测:99分位数与pinball loss工程
简介:面向能源负荷预测学习者与电力数据研究者的 GEFCOM2014 实践资源,围绕多地区小时级负荷数据的建模与预测展开,适合用 R 做时间序列分析、特征工程和模型评估的初中级读者。压缩包约111.53MB,文件总数显示为0,文件类型明细暂未提供,使用前需留意包内实际结构与数据完整性。已有1426人学习下载,说明该主题在电力数据科学场景中具有一定关注度。内容可支撑从负荷序列可视化、趋势与季节性分解,到 ARIMA、状态空间及随机森林等方法对比的学习路径,并涉及温度、节假日等外生变量处理、MSE/RMSE/MAE/R² 指标评估、参数调优与多模型集成思路,便于读者搭建可复现的预测流程并理解误差来源。
1. 冬天里那份要 99 条分位线的负荷表
前年冬天,一个做园区能源调度的朋友丢给我一个压缩包,说他不想再要“明天大概多少度电”这种单点答案了,他要未来两天的逐小时负荷,而且每个小时都要 99 条分位数线,好直接拿去算备用容量和购电申报区间。我当时心想,这活儿用点预测糊弄肯定过不去,打开包才发现里面是一套 GEFCOM2014 能源负荷预测的复现工程,主语言是 R,负荷、电价、风电、光伏四个赛道的数据组织、特征脚本、分位数回归训练和评分代码都在里面,目录干净得像教学仓库,但该有的坑一个不少。它适合两类人:一类是刚接触概率预测、想拿真实赛题数据把 pinball loss 跑通的新手;另一类是手里有负荷数据、准备做区间预测上线的熟手。这篇就从这份工程怎么跑、怎么改讲起。
2. 工程全貌:四个赛道怎么分,R 脚本怎么分层
GEFCOM2014 这套赛题最容易被低估的地方,是它并不只给了你一份“历史负荷 + 温度”的表。四条赛道并行,每条赛道的预测跨度、时间分辨率、外生变量都不一样,如果一开始就闷头写模型,后面非常容易在时间轴对齐上翻车。这份工程的第一个价值,就是它先把目录和脚本按照“数据 → 特征 → 模型 → 评估”切开,让人一眼能看出哪一段该改。
2.1 四个赛道各自的预测粒度和评分口径
负荷赛道是主线,也是代码量最大的一块。训练历史从 2005 年起,按小时给出区域负荷,外部变量是多个气象站的温度,任务被切成 15 个滚动任务,每个任务往前推一步、预测未来 48 小时,每个小时输出 99 个分位数(0.01 到 0.99)。这个设计很贴近真实调度:你给出的不是一条线,而是一整个分布的切片。
电价赛道把预测跨度压到 24 小时,输入是历史电价和负荷,输出同样是 99 个分位数;风电赛道原始数据是 10 分钟粒度,任务按 k 步向前预测,需要先聚合到任务要求的步长;光伏赛道按小时给功率,输入里多了辐照度和温度。四条赛道的评分口径统一为 pinball loss(也叫分位数损失),对每个分位数单独算一遍再平均。这个口径很关键,它意味着你优化的是分布形状,而不是误差绝对值。
| 赛道 | 预测跨度 | 原始分辨率 | 输出 | 主要外生变量 |
|---|---|---|---|---|
| 负荷 | 48 小时 | 1 小时 | 99 个分位数 | 多站温度、日历 |
| 电价 | 24 小时 | 1 小时 | 99 个分位数 | 历史电价、负荷 |
| 风电 | 多步(k 步) | 10 分钟 | 99 个分位数 | 风速、风向 |
| 光伏 | 24 小时 | 1 小时 | 99 个分位数 | 辐照度、温度 |
看懂这张表,后面所有代码的存在理由就清楚了:为什么特征脚本要按horizon拆、为什么模型要循环 99 次、为什么评估函数要接收一个分位数参数。
2.2 目录结构与脚本职责切分
工程的目录大致是下面这个样子,我第一次拿到时照着它建了一遍,后面加赛道基本没再动结构:
gefcom2014-epfl/ ├── data/ │ ├── Load/ 负荷赛道原始 csv │ ├── Temperature/ 气象站温度 csv │ ├── Price/ 电价赛道 │ ├── Wind/ 风电赛道 │ └── Solar/ 光伏赛道 ├── R/ │ ├── 00-load-data.R 统一读取入口 │ ├── 01-feature-engineering.R │ ├── 02-baseline.R 两个基线 │ ├── 03-train-quantile-gbm.R │ ├── 04-backtest.R 滚动回溯 │ └── 05-score.R pinball loss ├── cache/ 特征矩阵缓存 ├── output/ 预测结果与评分 └── README.md脚本编号不是装饰,是执行顺序。00负责把所有 csv 读成统一的data.table,01只做特征、不碰模型,02是无脑基线,用来兜底——如果复杂模型的分数打不过基线,问题一定在特征或者切分上,而不是模型不够深。03训练 99 个分位数模型,04做滚动回溯,05只做一件事:算分。这种切法有个实际好处,调参时你只需要重跑03和05,特征矩阵进了cache/就不用反复构造。
2.3 数据表字段与时间轴对齐
所有赛道的 csv 命名不太一致,列名大小写也混着来,所以读取层要做得笨一点、稳一点。我一般会把所有赛道都收敛到同一个读取函数上:
# R/00-load-data.R # 所有赛道的数据都从这里进,保证时间列类型一致 suppressPackageStartupMessages({ library(data.table) }) read_track <- function(dir, prefix, tz = "UTC", fmt = "%Y%m%d %H:%M") { files <- list.files(dir, pattern = paste0("^", prefix, ".*\\.csv$"), full.names = TRUE) stopifnot(length(files) > 0) # 路径写错时立刻报错,别静默返回空表 rbindlist(lapply(files, function(f) { d <- fread(f) # 大文件比 read.csv 快一个量级 setnames(d, tolower(names(d))) # 列名统一小写,躲开大小写差异 d[, timestamp := as.POSIXct(timestamp, tz = tz, format = fmt)] d[] }), use.names = TRUE, fill = TRUE) }这里fmt必须对着原始文件改,不同赛道的时间串格式可能不是一套;tz固定成UTC是我踩过坑之后的习惯,混用本地时区会在夏令时切换那天凭空多出或吃掉一小时,后面按小时做滞后特征就会错位。fill = TRUE是给不同赛道列数不一致留的余地。
读完还得验一遍时间轴连不连续,缺小时在滞后特征里会变成一片NA,而且不会报错:
# 检查每个区域的时间戳是否有断档 check_gaps <- function(dt, id_col = "zoneid") { dt[order(get(id_col), timestamp), { gap <- as.numeric(diff(timestamp), units = "hours") .(n_gap = sum(gap != 1, na.rm = TRUE), first = min(timestamp), last = max(timestamp)) }, by = id_col] }n_gap大于 0 就要回头看原始数据,是漏采还是夏令时。这一步花两分钟,能省掉后面半天的排查。
3. 特征工程:温度、时刻与滞后项怎么拼成设计矩阵
负荷预测的模型再花哨,特征没做对也是白搭。这份工程把特征层单独拆出来,我认为是整个仓库里最值得抄的部分。它做三件事:把时间编码成模型能吃的形式、把多站温度压成几条有物理含义的曲线、按预测步长分别构造滞后项。
3.1 时间编码与节假日哑变量
小时、星期、月份这些字段直接塞数字进去是有问题的:23 点和 0 点在数值上离得最远,但在负荷形态上几乎挨着。常见做法是做周期编码,用正弦余弦把环形的量摊到平面上:
# R/01-feature-engineering.R build_calendar <- function(ts) { h <- as.integer(format(ts, "%H")) w <- as.integer(format(ts, "%u")) # 1 = 周一,7 = 周日 data.table( hour = h, wday = w, month = as.integer(format(ts, "%m")), wkend = as.integer(w >= 6), hour_sin = sin(2 * pi * h / 24), hour_cos = cos(2 * pi * h / 24) ) }hour_sin和hour_cos是成对出现的,少一个就丢信息;wkend用 0/1 而不是让树自己去切wday,是因为周末的负荷形态和工作日差得太远,显式给一个标记收敛更快。节假日这块工程里给的是一张日期表加哑变量,节前节后各加一天,实际项目里我还会补一个“调休上班日”的标记,这类日期在负荷上经常出现反直觉的尖峰。
温度也是同理,年均温这种特征没用,真正有用的是“今天比昨天热多少”和“连续三天高温”。
3.2 温度的非线性变换与多站点聚合
负荷和温度不是线性关系,两头高中间低,典型的 U 形。工程里的处理是先把多个站点的温度平滑再聚合,然后用度日数把非线性显式写出来:
build_temp <- function(temp, stations = NULL) { if (!is.null(stations)) temp <- temp[station_id %in% stations] setorder(temp, station_id, timestamp) # 先按站点做 24 小时中心滑动平均,压掉单站噪声和传感器毛刺 temp[, t_smooth := frollmean(temperature, n = 24, align = "center", na.rm = TRUE), by = station_id] agg <- temp[, .(temp_mean = mean(t_smooth, na.rm = TRUE), temp_max = max(temperature, na.rm = TRUE), temp_min = min(temperature, na.rm = TRUE)), by = timestamp] # 18 度作为平衡点:高于它算制冷负荷,低于它算制热负荷 agg[, hdd := pmax(18 - temp_mean, 0)] agg[, cdd := pmax(temp_mean - 18, 0)] agg[] }frollmean的align = "center"很讲究,用右对齐会让平滑后的温度整体滞后,和负荷对不上;by = station_id保证不会把不同站点的数据混着平滑。聚合时保留temp_max和temp_min,是因为极端温度对应的负荷响应比均值更陡。hdd和cdd的 18 度不是唯一答案,做园区项目时我一般会画一张温度-负荷散点图找出拐点再定,用错平衡点会让模型把制冷段和制热段混成一团。
如果温度站点有缺失,别急着用 0 填,先按站点做线性插值再聚合,否则均值会被拉偏。
3.3 滞后、滑动窗口与按步长分别构造
负荷预测最有效的特征永远是历史负荷本身,但这里有个致命细节:预测未来 48 小时时,第 30 小时的“昨天同一时刻”其实在预测起点之后,是未知的。所以滞后特征必须按预测步长分别构造,所有 lag 的锚点都要早于预测起点:
make_lag_features <- function(dt, origin_ts, horizon, lags = c(24, 48, 168)) { hist <- dt[timestamp <= origin_ts] # 只允许使用起点之前的数据 out <- vector("list", horizon) for (h in seq_len(horizon)) { target <- origin_ts + h * 3600 row <- data.table(timestamp = target, h = h) for (L in lags) { # 硬约束:滞后时刻必须早于或等于预测起点,否则就是泄漏 if (target - L * 3600 <= origin_ts) { row[[paste0("lag", L)]] <- hist[timestamp == target - L * 3600, load] } else { row[[paste0("lag", L)]] <- NA_real_ } } out[[h]] <- row } rbindlist(out, fill = TRUE) }lags = c(24, 48, 168)覆盖了日周期、两日周期和周周期;h本身也要作为特征进模型,让树知道这是第几步预测,不同步长的误差分布完全不同。那个if判断是整个函数的核心,它把“用不了的特征”显式置成NA而不是偷偷用一个未来值。有人会问那h = 30时 lag24 怎么办,答案是换成“预测起点前 24 小时同刻负荷”这类固定锚点特征,或者干脆把 48 小时拆成两个模型,前 24 小时一套特征、后 24 小时另一套。
滑窗统计也类似,frollmean(load, 24)只能算到起点为止,往后一步就是未来。我习惯在构造完特征后跑一遍断言:所有特征列的最大时间戳都不能超过origin_ts。
4. 模型链路:分位数回归梯度提升在 R 里怎么落地
特征矩阵备好之后,模型这块反而直白。这份工程用的是梯度提升树配合分位数损失,R 里能做这件事的包不多,gbm是最省事的一个,distribution = list(name = "quantile", alpha = q)直接支持任意分位数。真正费时间的是 99 个分位数要拟合 99 次,以及拟合完之后怎么保证分位数不交叉。
4.1 基线先行与 pinball loss 的正确写法
在跑任何复杂模型之前,工程里先放了两个基线:一个是季节性朴素法(用上周同一时刻的负荷),一个是分小时的历史均值。它们的意义不是拿高分,而是给你一条及格线。基线代码如下:
# R/02-baseline.R seasonal_naive <- function(history, origin_ts, horizon) { targets <- origin_ts + seq_len(horizon) * 3600 base <- history[timestamp %in% (targets - 7 * 24 * 3600)] setkey(base, timestamp) data.table( timestamp = targets, pred = base[J(targets - 7 * 24 * 3600), load] ) }targets是未来 48 个整点,- 7 * 24 * 3600把每个点平移一周,J()是data.table的连接语法,按时间戳取值。这个基线在平稳周表现不差,一旦遇到节假日或者寒潮就崩,正好用来验证你的特征有没有真的起作用。
评分函数单独一个文件,只做一件事:
# R/05-score.R pinball <- function(actual, predicted, q) { e <- actual - predicted mean(pmax(q * e, (q - 1) * e), na.rm = TRUE) }q * e和(q - 1) * e分别对应高估和低估两侧,pmax取较大的那个,再按样本平均。na.rm = TRUE要留着,但跑完必须统计NA的比例,NA太多说明特征对齐出了问题,这时候分数再好看也是假的。负荷赛道的最终得分是所有分位数、所有任务、所有区域的 pinball loss 平均,所以低分位数和高分位数一样重要。
4.2 用 gbm 拟合 99 个分位数模型
单分位数的训练函数很短:
# R/03-train-quantile-gbm.R fit_quantile_gbm <- function(train_dt, q, features, n_trees = 1200) { fml <- as.formula(paste("load ~", paste(features, collapse = " + "))) gbm::gbm( formula = fml, data = train_dt, distribution = list(name = "quantile", alpha = q), n.trees = n_trees, interaction.depth = 4, shrinkage = 0.02, bag.fraction = 0.7, n.minobsinnode = 20 ) } qs <- seq(0.01, 0.99, by = 0.01) models <- parallel::mclapply(qs, function(q) { fit_quantile_gbm(train_dt, q, features) }, mc.cores = max(1L, parallel::detectCores() - 2L))参数得说清楚:alpha就是分位数,0.5 时退化成中位数回归,0.01 和 0.99 会明显更难收敛;n.trees给到 1200 是配合小shrinkage用的,shrinkage = 0.02学得慢但稳,interaction.depth = 4允许四阶交互,对“温度 × 时段”这类组合够用;bag.fraction = 0.7每次抽 70% 样本建树,方差能压下去一截;n.minobsinnode = 20防止叶子节点被极端样本带跑。mclapply的mc.cores留两个核给系统,99 个模型不至于把机器跑死。
这里有个现实问题:99 个模型各自独立训练,拟合出来的分位数并不保证单调。0.4 分位数的预测值可能比 0.5 还大,一旦出现,下游算区间宽度就会得到负数。
4.3 分位数重排与滚动回溯验证
处理分位数交叉最省事的办法是事后排序,业内叫 rearrangement,把每个样本的 99 个预测值从小到大重排一遍:
rearrange <- function(pred_mat) { # pred_mat 行是样本,列按 q 从小到大排列 t(apply(pred_mat, 1, sort)) }这个操作在理论上不会让 pinball loss 变差,实现成本几乎为零,我基本无脑加在预测出口。代价是重排之后单看某一个分位数的解释性会弱一点,但对调度场景没影响。
验证环节用滚动回溯,不能简单做一次训练集/测试集切分:
# R/04-backtest.R backtest <- function(dt, origins, horizon, fit_fn, predict_fn) { rbindlist(lapply(origins, function(o) { # 训练集截止到起点前 horizon 小时,避免标签泄漏 train <- dt[timestamp <= o - horizon * 3600] fit <- fit_fn(train) predict_fn(fit, o, horizon) })) }origins是一串预测起点,每次只往前推一个窗口,模拟真实上线时“每天重新训练一次”的节奏。train的截止点要往前退horizon * 3600,因为最后 48 小时的负荷虽然已知,但它们的标签在真实场景里是随着预测窗口一起产生的,直接拿来训练会让离线分数虚高。这件事我在第一次跑的时候完全没意识到,离线 pinball loss 比基线好了三成,上线之后直接打回原形,血泪经验。
5. 避坑与排查:五个让我返工的记录
这套工程跑通不难,难的是跑出来的分数可信。下面五条是我和同事在这份代码上真实翻过的车,按“现象 → 原因 → 解决”记下来,遇到类似症状可以直接对照。
5.1 数据泄漏与时间轴类问题
第一条:离线分数好得离谱,上线完全失效。现象是回溯测试的 pinball loss 只有基线的六成,换一批预测起点就崩。原因是特征构造时用了timestamp <= target的过滤条件,把预测窗口内的真实负荷也带进了训练集,等于把答案提前告诉模型。解决办法是把所有历史过滤条件统一改成timestamp <= origin_ts,并且在特征函数末尾加一条断言,检查特征矩阵里最大时间戳不超过起点。这个断言现在是我每个预测项目的标配,写完就忘不掉。
第二条:夏令时切换那天,滞后特征整列变 NA。现象是每年三月和十月各有一次分数异常,其他时间正常。原因是温度表用本地时区解析,负荷表用 UTC,拼接之后那两天少一小时或多一小时,按timestamp == target - 24 * 3600精确匹配就匹配不上。解决办法是读取层强制统一tz = "UTC",再跑一遍check_gaps看断档数量;匹配逻辑从精确等值改成roll = "nearest"的滚动连接,容忍少量偏移。
第三条:节假日附近误差肉眼可见地大。现象是春节、国庆前后几天的预测曲线整体偏低或偏高,平日误差正常。原因是特征里只有周末标记,节假日被当成普通工作日。解决办法是加一张节假日表生成哑变量,节前节后各覆盖一天,另外补一个“连续休假天数”的计数特征,因为长假的第三、四天负荷形态和第一天完全不同。
5.2 模型与评估类问题
第四条:99 个分位数互相交叉,区间宽度算出负数。现象是下游代码报“区间下界大于上界”。原因是每个分位数独立拟合,树模型之间没有单调约束。解决办法是预测出口统一走rearrange排序,再检查排序前后的 pinball loss 差值,正常情况下应该持平或略降;如果降得很多,说明模型本身不稳,得回头调n.trees和shrinkage。
第五条:评分函数把 NA 当成了 0 误差。现象是某次改动之后分数突然变得极好。原因是预测结果里有一批时间戳没对齐上,actual为NA,pinball里的na.rm = TRUE把这些样本整个剔掉了,剩下的都是容易预测的点。解决办法是在评分前先做一次完整性检查,统计actual和predicted的NA比例,超过千分之一就停下来查对齐逻辑,而不是继续看分数。这次之后我把“先查缺失率,再看分数”写进了脚本开头。
另外补一句,gbm在极端分位数(0.01、0.99)上容易过拟合,如果某几个分位数的 loss 明显高于邻居,先怀疑树太深,再怀疑特征。
6. 二次开发:把单赛道脚本改成配置驱动的流水线
单赛道跑通之后,真正要花时间的是把它变成能复用的东西。我当时的做法是把赛道差异全部抽到一张配置表里,脚本只认配置不认赛道名:
# config 里每个赛道一行,脚本按行循环 tracks <- data.table( name = c("load", "price", "wind", "solar"), target = c("load", "price", "power", "power"), horizon = c(48L, 24L, 12L, 24L), step_hour = c(1L, 1L, 2L, 1L), lags = list(c(24, 48, 168), c(24, 48), c(6, 12, 36), c(24, 48)) )循环体里只做三件事:按name读数据、按horizon和step_hour构造目标时间轴、按lags生成滞后特征,模型和评分完全复用。风电赛道的 10 分钟原始数据要先聚合到step_hour,这个聚合步骤放在读取层之后、特征层之前,别混进模型代码里。
改造的时候有个技巧值得说:把特征矩阵按(track, origin_ts)落盘缓存,文件名带上特征版本号。调模型参数时只重跑训练,不动特征,一轮 99 个分位数的拟合时间能省掉七成。验证方式也别偷懒,改完流水线先只跑一个赛道的一个任务,确认 pinball loss 和改造前一致到小数点后三位,再放开全量;不一致就说明某一层的默认参数被悄悄改掉了,这种情况我遇到过两次,都是配置表里漏了一列导致的。
最后一个习惯:每次跑完全量,我会把各分位数的 loss 单独画一条曲线看一眼形状。正常情况应该是一条平滑的 U 形,两端略高;如果中间某个分位数突然冒尖,多半是那一步的特征对齐出了问题,而不是模型本身。从那以后我每次接新的负荷预测项目,都会先跑基线、再跑单任务、最后才铺全量,三步不走完不提交代码。希望这套流程帮到你,少走我当年那几个弯路。
本文还有配套的精品资源,点击获取