简介GEFCOM2014-EPFL能源负荷预测数据包聚焦电力系统短期负荷预测面向数据科学研究者、R语言用户及能源电力从业人员。包体约111.53MB提供多地区小时级负荷记录可作为时间序列分析、特征工程与机器学习建模的实践数据集。已有1424人学习使用。借助R中的ts对象、forecast包、caret包及ggplot2可完成趋势分解、ARIMA或状态空间模型构建、随机森林/SVM/神经网络对比、交叉验证与误差评估。资源包含从数据导入、可视化到模型优化的完整探索路径适合教学练习及赛题复现结合温度、节假日等外生变量还能进一步验证特征工程对精度的提升。对希望掌握负荷预测全流程的读者而言是一份可直接上手的数据基础与实操指引。1. 能源负荷预测第一道坎GEFCOM2014 数据对齐与这个 EPFL 资源包GEFCOM2014 是能源负荷预测圈绕不开的公开数据集但真正动手时你会发现最耗时间的不是调模型而是把时间戳、温度和节假日对齐到同一条时间线上。gefcom2014-epfl这个资源包把 EPFL 整理的负荷数据和 R 脚本绑在一起省掉了数据清洗环节的重复劳动适合刚入行的 R 用户和需要快速建立 baseline 的工程师。这篇文章会从数据读取、特征构造、基线模型到概率评价完整过一遍最后给出我在这个数据集上踩过的五个坑。整篇的代码都是 base R 加少量时间序列包不依赖特定 IDE复制到 RStudio 就能跑。2. 资源包拆解先看清数据文件再谈建模2.1 目录结构与 R 依赖准备这类资源包通常长这样data/下面是负荷和温度的 CSVscripts/下面是数据清洗和建模的 R 脚本还会有一个README说明字段含义。下载解压后第一件事不是打开 RStudio而是打开 README 看字段定义因为 GEFCOM2014 的数据在历次复现中被改动过多次有些版本的load列名是小写有些是LOAD还有的将缺失值写成字符串NA而不是真正的NA。list.files(gefcom2014-epfl, recursive TRUE)这条命令会递归列出所有文件让你先掌握整体结构。如果发现scripts/下已经有别人写好的clean_data.R我建议先跑一遍再自己重写因为官方整理的数据格式很可能与你下载到的版本有细微差异直接重写反而容易漏掉细节。R 依赖建议先装lubridate、zoo、quantreg、xgboost。这里有个小细节quantreg依赖的SparseM包在某些 Linux 系统上有编译问题如果安装失败先单独安装SparseM再装quantreg。install.packages(c(lubridate, zoo, quantreg, xgboost))安装时不要一次装全部因为xgboost在 Windows 上通常有预编译包但在老版本 R 上可能链接失败。装完以后用packageVersion(xgboost)确认版本我遇到过 1.6 和 1.7 之间quantile目标函数的参数名不兼容导致同样的代码在不同电脑上结果不同。2.2 数据分区与变量名识别GEFCOM2014 的负荷预测数据集一般会按训练和测试分成两个文件。训练文件包含历史负荷和对应的温度观测测试文件只给时间点与温度预报不给你真实负荷需要你自己预测。常见列名如下表所示但具体以你下载到的 README 为准文件常见列说明load_train.csvtimestamp,load,temp训练期的小时级负荷与温度观测load_test.csvtimestamp,temp测试期只有时间与温度预报temp_obs.csvtimestamp,temp_obs温度观测值可能与负荷文件分开提供temp_forc.csvtimestamp,temp_forc温度预报值模拟真实业务场景train_raw - read.csv(data/load_train.csv, header TRUE, stringsAsFactors FALSE) str(train_raw) head(train_raw)str()能直接暴露时间列有没有被读成字符串、负荷列是不是numerichead()则让你快速扫一眼日期格式。很多新手在这一步跳过结果后面as.POSIXct()全错。我一般会再跑一条summary(train_raw$load)确认没有负数或极端尖峰负荷数据出现负数十有八九是计量单位或者 NA 被填成了 0。如果load列有负值用which(train_raw$load 0)定位到具体行别急着删先看那个时间点前后有没有记录异常。2.3 时间戳重对齐时区、夏令时与小时序列GEFCOM2014 的数据是按小时记录的但北美地区有夏令时切换3 月的某天会少一小时11 月的某天会多一小时。如果直接as.POSIXct却没处理时区会出现连续两个相同的小时戳或者缺少某个小时。R 里最常用的做法是统一用 UTC 解析再按业务时间轴对齐。library(lubridate) train_raw$datetime - as.POSIXct(train_raw$timestamp, tz UTC) train_raw$hour - hour(train_raw$datetime) train_raw$weekday - wday(train_raw$datetime, label TRUE) # 检查重复时间戳 dup_idx - duplicated(train_raw$datetime) sum(dup_idx)如果sum(dup_idx)大于 0说明原始数据里混入了夏令时未校准的时间或者同一小时有两个记录。处理办法是先把重复项打出来看是两个样本重合还是时间跳变。若是夏令时切换导致的缺失用seq.POSIXt生成完整小时序列再left_join回原数据把缺的负荷行留空让后续的缺失值处理统一接管。这里有个玄学问题你永远不知道发布者当初用的什么时区。所以不要盲目相信代码里的tz参数每次解析后都手动打印head(train_raw$datetime, 5)和tail(train_raw$datetime, 5)与原始字符串逐一比对。血泪经验是我曾在某次复现中因为时区差了 8 小时导致周末哑变量全部错位模型在凌晨时段残差异常放大排查了两天才发现是as.POSIXct默认用了本地时区。2.4 温度序列的合并方式负荷预测里温度是最强外因但温度观测和负荷往往不在同一张表里。资源包若单独给了temp_obs.csv和temp_forc.csv需要按datetime精确连接。连接时要用左连接保证负荷表每一行都保留。temp_obs - read.csv(data/temp_obs.csv, header TRUE) temp_obs$datetime - as.POSIXct(temp_obs$timestamp, tz UTC) df - merge(train_raw, temp_obs[, c(datetime, temp_obs)], by datetime, all.x TRUE)merge的by参数必须明确写清楚。如果不写R 会按所有同名列做笛卡尔积数据量稍大就直接内存爆炸。all.x TRUE是保留负荷侧所有行配合后面的缺失值处理比inner_join更能暴露数据断点。我一般还会检查合并后行数有没有变stopifnot(nrow(df) nrow(train_raw))stopifnot是 R 里一个廉价断言行数不一致就立刻报错避免你带着隐患继续往下跑。温度观测和温度预报要分开列。测试集上只有预报值模型如果混合使用观测值和预报值上线时会因为温度误差而被带偏。我在做项目时会把temp_obs和temp_forc都放进特征矩阵训练期用观测值测试期用预报值让模型自己学温度不确定性的影响效果比只塞一个温度列更稳。2.5 缺失值的版图先看缺在哪再决定怎么填数据清洗里最盲目的做法是全局na.omit()直接删掉一整行。GEFCOM2014 的负荷和温度缺失往往集中在特定时段比如某些天的凌晨或极端天气前后。删除前先画缺失版图。df$date - as.Date(df$datetime) daily_missing - aggregate(is.na(df$temp_obs), by list(df$date), FUN mean) colnames(daily_missing) - c(date, temp_na_ratio) daily_missing[daily_missing$temp_na_ratio 0.1, ]这里的aggregate(..., FUN mean)会把布尔向量转成缺失比例。如果缺失集中分布在连续几天说明是数据源断档后续填充要谨慎如果是零星几点线性插值就够用。不要一上来就用同一个填法覆盖全部时间范围。还要检查负荷本身的缺失load_na_idx - which(is.na(df$load))如果负荷列有缺失把它当作待预测目标会让训练集和验证集的关系变复杂。最简单的做法是训练时先删掉这些行但验证时要把缺失位置的预测单独留下来与后续真实值对比否则你无法知道模型在缺失段上表现如何。另外温度缺失超过 3 天的连续时段用插值等于编数据不如直接用温度预报值做替代。负荷数据里有一句行业老话温度是最大的外因但温度缺失的地方往往就是极端天气发生的地方。你插值抹平的恰恰是模型最该学习的极端样本。3. 特征工程与基线模型先让残差站得住3.1 负荷序列的周期性特征小时、星期与双驼峰电力负荷曲线在大多数地区呈现早晚双峰早上起床后用电上升傍晚下班后达到最高点深夜跌到低谷。这个形状在 GEFCOM2014 数据里同样存在。因此小时是最基础的特征但不能直接把小时数值 0-23 丢给线性模型因为线性模型会认为 0 和 23 之间距离是 23而实际上它们只隔一小时。用正弦余弦编码能把循环关系保留下来。df_sorted - df[order(df$datetime), ] df_sorted$hour_sin - sin(2 * pi * df_sorted$hour / 24) df_sorted$hour_cos - cos(2 * pi * df_sorted$hour / 24) df_sorted$weekday_sin - sin(2 * pi * as.numeric(df_sorted$weekday) / 7) df_sorted$weekday_cos - cos(2 * pi * as.numeric(df_sorted$weekday) / 7)hour如果是从lubridate::hour()得到的范围是 0-23除以 24 后乘以2 * pi正好一周循环。weekday是因子先用as.numeric()转成 1-7再同样做循环编码。这个技巧对线性回归和 GBM 都有用尤其是线性回归能够显著降低对小时边界的敏感度。如果不用 sin/cos而直接把 0 到 23 的整数喂进lm()模型会把 23 点视为离 0 点最远的点但实际上它们是相邻的。滞后特征也是负荷预测的标配。负荷在时间上高度自相关今天上午 10 点的负荷大概率与昨天上午 10 点接近也接近上周同一天上午 10 点。所以滞后 24 小时和滞后 168 小时是必加的特征。df_sorted$lag24 - dplyr::lag(df_sorted$load, 24) df_sorted$lag168 - dplyr::lag(df_sorted$load, 168) df_sorted$temp_ma7 - zoo::rollmeanr(df_sorted$temp_obs, 7, fill NA)dplyr::lag()是按行位置错位所以必须先order()保证时间升序。lag168后面的 168 个样本会是NA导致它们进不了模型。这不是问题只要保证你的训练集按时间顺序过滤掉了这些行。但如果先做随机重排再做lag()滞后特征就全部错乱这是最容易犯的隐蔽错误。3.2 线性回归基线系数符号与残差诊断我必须在任何复杂模型之前强制自己跑一个线性回归因为线性回归能暴露特征构造的合理性。比如温度对负荷的影响理想情况下应该是正系数——温度升高制冷负荷增加但如果你的数据是从暖气为主的地方采集的温度升高反而降低负荷系数符号也会不同。看到系数符号与直觉不符时先别调模型回头查数据。model_lm - lm(load ~ hour_sin hour_cos weekday_sin weekday_cos temp_obs lag24 lag168, data df_sorted, na.action na.exclude) summary(model_lm)na.action na.exclude比默认na.omit更讲究它让predict()返回和原数据等长的向量NA 位置保持 NA方便你计算误差时自动跳过。summary()里重点看两个地方temp_obs的系数符号以及lag168的显著性。如果lag168不显著说明跨周信息没有用反而要小心模型过拟合。残差诊断我一般会画一张预测值与真实值的散点图重点关注高负荷段是否系统性偏低那通常是高峰特征没有构造好。3.3 梯度提升参数选择与早停当线性回归的残差已经不再有明显的时间模式后再上梯度提升。在 R 里调xgboost时小时级数据会把类别特征直接做成 0/1 哑变量。GEFCOM2014 这个量级的数据lightgbm 的直方图算法优势不明显xgboost反而更容易控制过拟合。我一般先用一组固定参数跑 baseline不做搜索目的是看特征能不能复用。library(xgboost) feature_cols - c(hour_sin, hour_cos, weekday_sin, weekday_cos, temp_obs, lag24, lag168) train_df - df_sorted[complete.cases(df_sorted[, feature_cols]), ] dtrain - xgb.DMatrix( data as.matrix(train_df[, feature_cols]), label train_df$load ) params - list( objective reg:squarederror, eta 0.05, max_depth 6, subsample 0.8, colsample_bytree 0.8, min_child_weight 3 ) model_xgb - xgb.train(params, dtrain, nrounds 300, verbose 0)objective reg:squarederror对应常规 RMSE 损失。如果你想输出分位数可以改成reg:quantileerror并设置quantile_alpha参数但这里有个坑不同版本 xgboost 的 quantile 参数名不一样有的叫quantile_alpha有的直接叫alpha运行前用xgb.parameters确认。eta 0.05和nrounds 300是保守组合适合先验证特征。min_child_weight 3是防止 GBM 在负荷尖峰上过分拟合因为负荷数据里极端高温日的样本量很少树很容易把单个异常样本学死。3.4 分位数预测从单点输出到区间如果业务只要求一个预测值上面的模型已经够了。但 GEFCOM2014 这类比赛以及电力系统实际调度都需要知道预测的不确定性。R 自带的quantreg包是最稳妥的分位数回归实现我一般会先跑几个关键分位数作为概率预测基线。library(quantreg) model_qr - rq(load ~ hour_sin hour_cos weekday_sin weekday_cos temp_obs lag24 lag168, tau c(0.05, 0.5, 0.95), data df_sorted, na.action na.exclude) pred_qr - predict(model_qr, newdata df_sorted)tau是分位数向量rq()会对每个分位数独立拟合。这种做法的好处是稳定、可解释缺点是当变量间关系非线性时偏差可能较大。实际应用中我经常先用quantreg做一个概率输出基线再让 GBM 去追中间值两边对照评估。注意predict()出来的矩阵列顺序与tau一致第一列是 0.05第二列是 0.5第三列是 0.95后续计算损失时不要搞反。4. 评价指标与验证脚本用分位损失替代单一 RMSE4.1 Pinball Loss 的公式与 R 实现RMSE 只衡量点预测的平均误差但分位数预测需要用分位损失来评估。分位损失也叫 Pinball Loss它对预测值落在真实值上方和下方惩罚不对称系数由分位数tau决定。当tau 0.5时损失退化为 MAE当tau 0.05时它主要惩罚预测值高于真实值的情况因为 5% 分位数的意思是真实值有 5% 的概率低于这个值。pinball_loss - function(y, q_pred, tau) { diff - y - q_pred loss - ifelse(diff 0, tau * diff, (tau - 1) * diff) mean(loss) }这个函数和数学公式一一对应。测试时用一个简单向量验证pinball_loss(c(10, 10), c(9, 11), tau 0.5)两个样本的绝对值误差都是 1平均损失也是 1。如果算出来不对说明diff符号判断错了。我在第一版写这个函数时把tau - 1写成了1 - tau结果所有分位损失都变成负数后面全盘错乱。对多分位数模型最终指标是各分位损失的加权平均。GEFCOM2014 官方评价时通常考察多个分位数GEFCOM2014 的负荷预测赛道在官方评价时使用的不是单一 RMSE而是把多个分位点上的 Pinball Loss 聚合起来。所以你在复现时不要只报 RMSE否则无法和其他公开结果对比。4.2 滚动窗口回测框架与参数负荷预测是时间序列随机划分训练集和测试集很容易高估模型表现。正确的做法是滚动前推验证用前 N 天训练预测后 M 天然后整个窗口往前滑。这样能模拟真实业务里你只有过去的数据未来永远是待预测的情况。train_days - 30 test_days - 3 horizon - 24 * test_days n_rows - nrow(df_sorted) results - list() for (start in seq(1, n_rows, by horizon)) { train_end - start train_days * 24 - 1 test_start - train_end 1 test_end - test_start horizon - 1 if (test_end n_rows) break fit - lm(load ~ hour_sin hour_cos weekday_sin weekday_cos temp_obs lag24 lag168, data df_sorted[start:train_end, ]) pred - predict(fit, newdata df_sorted[test_start:test_end, ]) actual - df_sorted$load[test_start:test_end] results[[length(results) 1]] - data.frame( time df_sorted$datetime[test_start:test_end], actual actual, pred pred, mid df_sorted$load[test_start:test_end] # 真实值用于计算误差 ) }seq(1, n_rows, by horizon)让起点每隔 3 天推进一次。循环里train_days * 24 - 1是为了对齐到整点test_end超过总行数就停止。这个框架能防住两件事一是避免未来数据泄露二是强制你检查模型在长周期上的稳定性。实际跑的时候我会把每轮预测结果存到 list 里而不是直接在循环里算指标方便后面统一画误差曲线。在真实项目里train_days是模型训练窗口太小会让模型学不到季节模式太大又会拖慢计算速度。我在 GEFCOM2014 上常用的窗口是 30 天因为负荷的季节模式在月度尺度上相对稳定30 天足够覆盖一个完整的周期变化。如果你要预测的是寒潮或热浪事件窗口需要拉长到 90 天否则模型没见过同样强度的极端温度。4.3 季节性折叠与冷启动检验滚动窗口回测还有一个变体季节性折叠验证。具体做法是把数据按月份分组每个月作为一次验证集其余月份作为训练集。这能直接暴露模型对季节漂移的敏感度。df_sorted$month - month(df_sorted$datetime) for (m in unique(df_sorted$month)) { train_part - df_sorted[df_sorted$month ! m, ] test_part - df_sorted[df_sorted$month m, ] fit - lm(load ~ hour_sin hour_cos weekday_sin weekday_cos temp_obs lag24 lag168, data train_part) pred - predict(fit, newdata test_part) rmse_month - sqrt(mean((pred - test_part$load)^2, na.rm TRUE)) print(paste(Month, m, RMSE:, round(rmse_month, 3))) }这个做法比随机 K 折更贴近电力业务的验证习惯。如果某个月的 RMSE 明显偏高再去看那个月是否包含节假日集中时段或极端天气。我曾在某个项目里发现 2 月误差大幅上涨查了一下是因为那年春节在 2 月而训练集里没有覆盖春节的负荷模式——这就是冷启动问题。资源包的原始数据若不包含目标年份的节假日你需要用外部节假日日历补充而不是让模型自己学。5. 常见问题与避坑五个让模型翻车的细节5.1 时间戳偏移一小时后周末标签全部错位现象用as.POSIXct直接转换后周末和节假日的哑变量在凌晨时段提前一小时触发白天预测误差不明显深夜误差突然放大。原因原始文件的时区元数据可能不是 UTC系统默认时区自动把时间往前挪了八小时。R 的as.POSIXct在未指定tz时会用系统时区如果你在中国Sys.timezone()返回Asia/Shanghai同一个字符串2012-08-01 00:00:00在 UTC 和 Asia/Shanghai 下对应实际时刻完全不同。问题不在于时间本身错而是后面的wday()提取出的星期几错位。解决读取后第一件事是打印head(datetime)并核对业务时间。如果发现整体偏移重新指定tz更稳妥的做法是在read.csv之后先as.character固定字符串再用lubridate::ymd_hms(tz UTC)转换转换后立即与原字符串逐行对比。5.2 全局填充温度 NA把极端天气直接抹平现象模型在识别高温和寒潮时完全失效残差在极端温度日突然放大。原因用均值或前值填充了缺失的温度而缺失往往集中在传感器离线的那几天恰好就是极端天气发生期间。GEFCOM2014 的温度缺失不是完全随机很多时候是温度传感器在极冷或极热条件下故障。你用均值填充等于把所有极端样本拉回正常水平模型当然学不到极端情况。解决先按date分组统计缺失比例对缺失连续超过 3 天的时段改用相邻年份同期的气象站数据插值或者干脆把该段从训练集中剔除。如果资源包自带温度预报优先用预报值做输入而不是历史观测均值。我在处理时还会加一个is_temp_missing布尔特征告诉模型这个样本的温度不可靠模型有时能自动调整加权。5.3 分位数结果不单调95% 分位小于 5% 分位现象predict(rq(...))出来的 0.95 分位数在某几个小时突然低于 0.05 分位数画出来分位数曲线交叉。原因分位数回归在样本量小或特征维度高时个别分位点的解出现局部不稳定更常见的是在newdata里有 NAna.action处理不一致导致对齐错位。还有可能是样本里存在极端离群点而rq()对离群点的处理比lm()更敏感。解决先过滤掉所有特征含 NA 的行再进predict()。检查分位数矩阵每一行是否单调递增用apply(pred_qr, 1, is.unsorted)找出交叉点。如果仍然交叉检查数据里是否有极端离群点对离群点做分位缩尾处理。分位数交叉在实际比赛中会被直接判为无效提交所以这种问题要提前在验证阶段捕捉。5.4 节假日列表写死导致跨年份预测失败现象模型在节假日日的预测普遍偏低误差呈明显的 7 天周期。原因训练集里用的节假日列表是某一年份的固定日期比如把 2012 年的节假日硬编码成向量测试集用到 2013 年甚至 2014 年时节日日期完全不同。负荷数据里的节假日效应很强春节、感恩节、圣诞节等节日当天和前后几天的负荷模式与平日差异很大。解决不要把节假日写死成向量。按年份动态生成每年对应的节假日再通过merge关联到每个时间戳。R 里可以用timeDate包获取主要国家的节假日或者手动维护一张年份-日期-节假日名映射表。资源包里的日期字段若带holiday标记优先使用标记若没有就自己建表。还要注意节假日前后各一天往往也有负荷变化我会把is_holiday、days_to_holiday、days_after_holiday一起作为特征。5.5 滞后特征跨窗口泄漏验证时好看上线就崩现象滚动窗口验证时模型表现极佳RMSE 很低但真实上线后误差翻倍。原因做滚动验证时你提前把整个数据集的lag168一次性算好了。测试集中的第 15 天样本它的lag168来自测试集前 7 天的真实负荷而真实预测场景里你不可能知道未来 168 小时的负荷。看代码最直观df_sorted$lag168 - dplyr::lag(df_sorted$load, 168)这行在循环外执行意味着测试集样本的滞后特征已经用到了它自身之后的真实值。解决在回测里重构特征时必须保证每个样本进入模型前所有滞后特征只依赖它自身时间点之前的值。我常用的方法是把特征构造写成一个带current_pos参数的函数lag_feature - function(dt, pos, period 168) { if (pos - period 1) return(NA) dt$load[pos - period] }在滚动循环内部每预测到某一行就调用这个函数而不是用预先算好的列。这样虽然慢一点但能彻底阻断泄漏。从那以后我每次拿到新数据都会强制跑一遍人工挖掉 3 天真实值再用模板预测的验证确认没有隐性的未来信息进入特征。6. 进阶把资源包变成你随时可复用的预测模板到这个阶段你手上已经有了能读数据、造特征、跑基线、算 Pinball Loss 的完整流程。我建议你再花半天做一件事把前面的代码收拢成三个函数load_gefcom()、build_features()、run_baseline()然后保存成forecast_utils.R以后任何新项目直接source()这些函数只需要改文件路径和日期范围。load_gefcom - function(path_load, path_temp, tz UTC) { load_raw - read.csv(path_load, stringsAsFactors FALSE) temp_raw - read.csv(path_temp, stringsAsFactors FALSE) load_raw$datetime - as.POSIXct(load_raw$timestamp, tz tz) temp_raw$datetime - as.POSIXct(temp_raw$timestamp, tz tz) merge(load_raw, temp_raw[, c(datetime, temp_obs)], by datetime, all.x TRUE) } build_features - function(dt) { dt$hour - lubridate::hour(dt$datetime) dt$hour_sin - sin(2 * pi * dt$hour / 24) dt$hour_cos - cos(2 * pi * dt$hour / 24) dt$weekday - lubridate::wday(dt$datetime, label TRUE) dt$lag24 - dplyr::lag(dt$load, 24) dt$lag168 - dplyr::lag(dt$load, 168) dt$temp_ma7 - zoo::rollmeanr(dt$temp_obs, 7, fill NA) dt }函数化最大的好处是验证和线上共用同一套特征逻辑不会出现线上少一个lag24的情况。参数上tz默认设为UTC但如果你的业务数据是本地时区调用时直接传新的时区名函数内部不需要改动。merge里用了all.x TRUE这能保证负荷行不丢但后续必须自己处理温度缺失避坑章节里强调过的点在这里依然适用。函数写完以后我还会做一个小验证从资源包原始数据随机挑出 7 天人为挖掉负荷值然后用模板流程预测这 7 天再用 Pinball Loss 比较预测值和被挖掉的值。这个测验的意义不在于指标多高而在于确认整个流程从数据读取到指标计算没有任何隐藏的 NA 或对齐问题。如果某个时间点的预测值比真实值差了几百兆瓦回头查那段时间的lag24是否因为缺失被错误填充。从那以后每拿到一份新的负荷数据我都会强制走一遍这个流程先统一时区再打印缺失版图然后构造滞后特征最后分位损失和区间稳定性一起看。这套习惯帮我避开过不少坑希望帮到你。本文还有配套的精品资源点击获取