XGBoost实战:手把手教你用Python预测电力负荷(附完整代码)
最近在做一个能源数据分析的项目,客户的核心需求是提前知道未来几小时甚至几天的用电量。这听起来像是经典的时间序列预测问题,但真正上手后才发现,电力负荷数据远没有股价或气温数据那么“友好”。它的波动既受明显的周期性(比如早晚高峰)影响,又对节假日、天气突变甚至大型社会活动异常敏感。尝试过ARIMA,也折腾过LSTM,最后发现,在数据量不是天文数字、且特征工程能做得比较细致的情况下,梯度提升树家族,尤其是XGBoost,往往能给出一个在精度、速度和可解释性上相当平衡的答案。
这篇文章,我就把自己从数据清洗、特征构造到模型调参、结果评估的完整流程拆解一遍。目标读者是已经熟悉Python和pandas基础操作,并希望将机器学习应用于实际时序预测场景的开发者。我们会避开空洞的理论,聚焦于代码和决策过程,你可以跟着文中的代码块一步步复现,最终得到一个能用的负荷预测模型。
1. 理解数据与问题定义
在动手写任何代码之前,搞清楚我们要预测什么以及数据长什么样至关重要。电力负荷预测,简单说就是基于历史用电量数据,预测未来某个时间点的用电需求。这个问题之所以具有挑战性,是因为它混杂了多种模式:
- 趋势性:长期来看,一个地区的用电量可能随着经济发展而缓慢增长。
- 季节性:以年为周期,夏季(空调)和冬季(取暖)的负荷通常更高。
- 周周期性:工作日的用电模式与周末截然不同。
- 日周期性:一天之内,存在明显的早高峰、午间平峰和晚高峰。
- 节假日效应:法定节假日会彻底打破常规的周周期模式。
- 外部因素:温度、湿度、天气状况(如暴雨、极端高温)对负荷有即时且显著的影响。
我们拿到的原始数据往往是一个CSV文件,至少包含两列:时间戳 (timestamp) 和对应的负荷值 (load)。数据质量是第一个拦路虎,缺失值、异常值(比如传感器故障导致的零值或极大值)非常常见。
注意:在实际项目中,务必先与业务方确认预测的“粒度”(是预测未来1小时,还是未来24小时每小时的值?)和“时效性”(模型需要多快给出预测?)。这直接决定了特征工程和模型选型的方向。
我们先看看如何加载和审视数据:
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
# 加载数据,假设时间列名为'time',负荷列名为'load'
df = pd.read_csv('power_load_data.csv')
df['time'] = pd.to_datetime(df['time']) # 确保时间列为datetime类型
df.set_index('time', inplace=True) # 将时间设为索引,便于时间序列操作
print(df.head())
print(f"\n数据时间范围:{df.index.min()} 到 {df.index.max()}")
print(f"数据总条数:{len(df)}")
print(f"是否存在缺失值:{df['load'].isnull().sum()}")
运行上述代码后,你应该能立刻知道数据的基本情况。接下来,用可视化来感受数据的脉搏:
fig, axes = plt.subplots(2, 2, figsize=(16, 10))
# 整体趋势
axes[0, 0].plot(df.index, df['load'], linewidth=0.5)
axes[0, 0].set_title('负荷整体时序图')
axes[0, 0].set_xlabel('时间')
axes[0, 0].set_ylabel('负荷 (MW)')
# 单日曲线(选取有代表性的一天)
sample_day = df['2023-07-15']
axes[0, 1].plot(sample_day.index.hour, sample_day['load'], marker='o')
axes[0, 1].set_title('典型日负荷曲线 (2023-07-15)')
axes[0, 1].set_xlabel('小时')
axes[0, 1].set_ylabel('负荷 (MW)')
axes[0, 1].grid(True)
# 按小时统计的箱线图(看分布)
df['hour'] = df.index.hour
sns.boxplot(x='hour', y='load', data=df, ax=axes[1, 0])
axes[1, 0].set_title('不同小时负荷分布')
axes[1, 0].set_xlabel('小时')
axes[1, 0].set_ylabel('负荷 (MW)')
# 按星期统计
df['dayofweek'] = df.index.dayofweek # Monday=0, Sunday=6
sns.boxplot(x='dayofweek', y='load', data=df, ax=axes[1, 1])
axes[1, 1].set_title('不同星期几负荷分布')
axes[1, 1].set_xlabel('星期几 (0=周一)')
axes[1, 1].set_ylabel('负荷 (MW)')
plt.tight_layout()
plt.show()
这几张图能迅速告诉你:负荷在一天中何时最高、何时最低;工作日和周末的用电模式差异有多大;以及数据中是否存在明显的异常点。我遇到过数据在凌晨3点出现一个本不该有的尖峰,后来发现是数据采集系统的定时校准任务被误记录为负荷,这就是可视化排查的价值。
2. 数据预处理与特征工程
这是决定模型性能最关键的步骤,没有之一。对于XGBoost这类树模型,特征工程的目标是把时间信息转换成模型能理解的、有预测力的数值特征。
2.1 处理缺失值与异常值
负荷数据很少完美。对于缺失值,简单的向前填充(ffill)或线性插值通常就够用,但更稳妥的方法是基于相似日进行填充(例如,用上周同一天同一时刻的值来填充)。对于异常值,可以用滑动窗口统计量(如中位数)来识别和替换。
def handle_missing_and_outliers(series, window=24*7):
"""
处理缺失值和异常值。
series: 负荷序列
window: 用于计算统计量的滑动窗口大小(小时)
"""
# 1. 处理缺失值:使用前向填充,如果还缺失则用后向填充
series_filled = series.ffill().bfill()
# 2. 检测异常值(使用滑动中位数和绝对中位差)
rolling_median = series_filled.rolling(window=window, center=True, min_periods=1).median()
# 计算绝对偏差
abs_diff = np.abs(series_filled - rolling_median)
# 计算绝对偏差的中位数(MAD)
mad = abs_diff.rolling(window=window, center=True, min_periods=1).median()
# 定义异常值阈值(例如,偏离中位数超过3倍MAD)
threshold = 3 * mad
is_outlier = abs_diff > threshold
# 3. 替换异常值为滑动中位数
series_cleaned = series_filled.copy()
series_cleaned[is_outlier] = rolling_median[is_outlier]
print(f"填充了 {series.isnull().sum()} 个缺失值")
print(f"检测并修正了 {is_outlier.sum()} 个异常值")
return series_cleaned
df['load_cleaned'] = handle_missing_and_outliers(df['load'])
2.2 构造核心时间特征
直接从datetime索引中提取丰富的特征。这里的关键是捕捉周期性。对于小时、星期几这类循环特征,简单的整数编码(0-23)会让模型误以为23点和0点相差很远,而实际上它们很接近。更好的方法是使用正弦余弦变换。
def create_time_features(df):
"""
从DatetimeIndex创建时间特征。
"""
df_feat = df.copy()
# 基础特征
df_feat['hour'] = df.index.hour
df_feat['dayofweek'] = df.index.dayofweek
df_feat['month'] = df.index.month
df_feat['dayofyear'] = df.index.dayofyear
df_feat['weekofyear'] = df.index.isocalendar().week
df_feat['is_weekend'] = (df.index.dayofweek >= 5).astype(int)
# 周期性编码:小时
df_feat['hour_sin'] = np.sin(2 * np.pi * df_feat['hour'] / 24)
df_feat['hour_cos'] = np.cos(2 * np.pi * df_feat['hour'] / 24)
# 周期性编码:星期几
df_feat['dayofweek_sin'] = np.sin(2 * np.pi * df_feat['dayofweek'] / 7)
df_feat['dayofweek_cos'] = np.cos(2 * np.pi * df_feat['dayofweek'] / 7)
# 周期性编码:月份
df_feat['month_sin'] = np.sin(2 * np.pi * (df_feat['month'] - 1) / 12)
df_feat['month_cos'] = np.cos(2 * np.pi * (df_feat['month'] - 1) / 12)
# 是否为工作日早高峰(例如 8-10点)或晚高峰(18-21点)
df_feat['is_morning_peak'] = ((df_feat['hour'] >= 8) & (df_feat['hour'] <= 10) & (df_feat['is_weekend'] == 0)).astype(int)
df_feat['is_evening_peak'] = ((df_feat['hour'] >= 18) & (df_feat['hour'] <= 21)).astype(int)
return df_feat
df = create_time_features(df)
2.3 构造滞后与滑动窗口特征
负荷预测中,过去的值对未来有极强的指示作用。我们需要创建滞后特征和滚动统计特征。
- 滞后特征:比如1小时前、24小时前(昨天同一时刻)、168小时前(上周同一时刻)的负荷值。这直接告诉模型“最近的用电情况”。
- 滚动统计特征:比如过去3小时的平均负荷、过去24小时的最大负荷等。这能捕捉短期的趋势和波动水平。
def create_lag_features(df, target_col='load_cleaned'):
"""
创建滞后和滚动窗口特征。
"""
df_lag = df.copy()
# 滞后特征
df_lag['lag_1h'] = df_lag[target_col].shift(1) # 1小时前
df_lag['lag_2h'] = df_lag[target_col].shift(2) # 2小时前
df_lag['lag_3h'] = df_lag[target_col].shift(3) # 3小时前
df_lag['lag_24h'] = df_lag[target_col].shift(24) # 昨天同时刻
df_lag['lag_168h'] = df_lag[target_col].shift(168) # 上周同时刻
# 滚动窗口统计特征
df_lag['rolling_mean_3h'] = df_lag[target_col].rolling(window=3, min_periods=1).mean()
df_lag['rolling_std_3h'] = df_lag[target_col].rolling(window=3, min_periods=1).std()
df_lag['rolling_mean_24h'] = df_lag[target_col].rolling(window=24, min_periods=1).mean()
df_lag['rolling_max_24h'] = df_lag[target_col].rolling(window=24, min_periods=1).max()
# 差分特征(捕捉变化率)
df_lag['diff_1h'] = df_lag[target_col].diff(1)
df_lag['diff_24h'] = df_lag[target_col].diff(24)
# 由于创建滞后特征会产生NaN,需要删除这些行
df_lag = df_lag.dropna()
return df_lag
df = create_lag_features(df)
print(f"创建特征后数据形状:{df.shape}")
现在,我们的数据框从最初的两列,扩展到了包含几十个特征。你可以用 df.corr()[['load_cleaned']].sort_values('load_cleaned', ascending=False) 快速查看哪些特征与目标变量相关性最高。
3. 构建与训练XGBoost模型
特征准备好后,就可以开始建模了。这里有几个关键决策点:如何划分训练集和测试集?如何为时间序列数据做交叉验证?如何设置XGBoost的初始参数?
3.1 数据集划分的陷阱
对于时间序列数据,绝对不能使用随机划分(比如train_test_split的默认shuffle=True),这会造成数据泄露(用未来的信息预测过去),导致模型评估结果虚高。必须按时间顺序划分。
from sklearn.model_selection import TimeSeriesSplit
from sklearn.metrics import mean_absolute_error, mean_squared_error
# 定义特征和目标
feature_columns = [col for col in df.columns if col not in ['load', 'load_cleaned']] # 排除原始目标列
X = df[feature_columns].values
y = df['load_cleaned'].values
# 按时间顺序划分:前80%训练,后20%测试
split_idx = int(len(X) * 0.8)
X_train, X_test = X[:split_idx], X[split_idx:]
y_train, y_test = y[:split_idx], y[split_idx:]
print(f"训练集大小:{X_train.shape}, 测试集大小:{X_test.shape}")
3.2 时间序列交叉验证
为了更稳健地调参,我们使用TimeSeriesSplit。它保证了在每一折中,训练数据都在验证数据之前。
# 初始化XGBoost回归器
import xgboost as xgb
from sklearn.model_selection import GridSearchCV
# 首先,我们用一个合理的默认参数训练一个基线模型
model_baseline = xgb.XGBRegressor(
objective='reg:squarederror',
n_estimators=100,
learning_rate=0.1,
max_depth=5,
subsample=0.8,
colsample_bytree=0.8,
random_state=42,
n_jobs=-1
)
# 使用时间序列交叉验证评估基线模型
tscv = TimeSeriesSplit(n_splits=3)
cv_scores = []
for train_idx, val_idx in tscv.split(X_train):
X_tr, X_val = X_train[train_idx], X_train[val_idx]
y_tr, y_val = y_train[train_idx], y_train[val_idx]
model_baseline.fit(X_tr, y_tr)
y_pred = model_baseline.predict(X_val)
score = mean_absolute_error(y_val, y_pred)
cv_scores.append(score)
print(f"基线模型交叉验证MAE: {np.mean(cv_scores):.2f} (+/- {np.std(cv_scores):.2f})")
3.3 超参数调优
XGBoost参数众多,手动调优费时费力。网格搜索(GridSearchCV)是系统的方法,但要注意搜索空间不宜过大。这里我们聚焦几个最重要的参数。
# 定义参数网格
param_grid = {
'max_depth': [3, 5, 7],
'learning_rate': [0.01, 0.05, 0.1],
'n_estimators': [100, 200, 300],
'subsample': [0.7, 0.8, 0.9],
'colsample_bytree': [0.7, 0.8, 0.9],
}
# 初始化模型
xgb_model = xgb.XGBRegressor(objective='reg:squarederror', random_state=42, n_jobs=-1)
# 使用时间序列交叉验证进行网格搜索
tscv = TimeSeriesSplit(n_splits=3)
grid_search = GridSearchCV(
estimator=xgb_model,
param_grid=param_grid,
cv=tscv,
scoring='neg_mean_absolute_error', # 我们关注MAE
verbose=1,
n_jobs=-1
)
print("开始网格搜索...")
grid_search.fit(X_train, y_train)
print(f"\n最佳参数:{grid_search.best_params_}")
print(f"最佳交叉验证分数(负MAE): {grid_search.best_score_:.4f}")
网格搜索可能耗时较长。在实际项目中,我更喜欢先用随机搜索(RandomizedSearchCV)在大范围参数空间里快速定位有希望的区域,再用网格搜索在小范围内精细调整。
3.4 训练最终模型与评估
用找到的最佳参数重新训练模型,并在真正的测试集(那20%的未来数据)上评估其泛化能力。
# 用最佳参数训练最终模型
best_model = grid_search.best_estimator_
# 或者手动指定调优后的参数
final_model = xgb.XGBRegressor(
objective='reg:squarederror',
**grid_search.best_params_, # 解包最佳参数
random_state=42,
n_jobs=-1
)
final_model.fit(X_train, y_train)
# 在测试集上进行预测
y_test_pred = final_model.predict(X_test)
# 计算评估指标
mae = mean_absolute_error(y_test, y_test_pred)
rmse = np.sqrt(mean_squared_error(y_test, y_test_pred))
mape = np.mean(np.abs((y_test - y_test_pred) / y_test)) * 100
print("=== 测试集性能评估 ===")
print(f"平均绝对误差 (MAE): {mae:.2f} MW")
print(f"均方根误差 (RMSE): {rmse:.2f} MW")
print(f"平均绝对百分比误差 (MAPE): {mape:.2f}%")
# 可视化预测结果 vs 真实值
plt.figure(figsize=(14, 6))
plt.plot(y_test[:168], label='真实负荷', alpha=0.8, linewidth=2) # 展示前一周的数据
plt.plot(y_test_pred[:168], label='预测负荷', alpha=0.8, linestyle='--')
plt.xlabel('时间 (小时)')
plt.ylabel('负荷 (MW)')
plt.title('测试集预测结果对比 (第一周)')
plt.legend()
plt.grid(True, alpha=0.3)
plt.show()
MAPE(平均绝对百分比误差)是一个业务方更容易理解的指标,它告诉你平均预测偏差了百分之多少。在电力负荷预测中,MAPE能控制在2%-5%通常就算很不错的表现了。
4. 模型解析与高级技巧
模型训练好不是终点,我们还需要理解它、信任它,并知道如何让它变得更好。
4.1 特征重要性分析
XGBoost提供了多种计算特征重要性的方法(weight, gain, cover)。gain是最常用的一种,它衡量了特征在所有树中被用于分裂时带来的平均增益。
# 获取特征重要性
importance_df = pd.DataFrame({
'feature': feature_columns,
'importance': final_model.feature_importances_
}).sort_values('importance', ascending=False)
print("特征重要性排名 (Top 10):")
print(importance_df.head(10))
# 可视化
plt.figure(figsize=(10, 8))
plt.barh(importance_df['feature'].head(15), importance_df['importance'].head(15))
plt.xlabel('特征重要性 (Gain)')
plt.title('Top 15 特征重要性')
plt.gca().invert_yaxis() # 最重要的在顶部
plt.show()
这个分析极具价值。你可能会发现 lag_24h(昨日同时刻负荷)和 hour_sin/hour_cos 是最重要的特征,这验证了我们特征工程的方向。如果某些精心构造的特征重要性极低,可以考虑在后续迭代中移除,以简化模型。
4.2 预测未来与多步预测
我们目前构建的是一个单步预测模型:用截至时间t的所有特征,去预测时间t的负荷。但实际业务往往需要预测未来多个时间点(例如,预测未来24小时每小时的负荷)。这被称为多步预测。
实现多步预测有两种主流策略:
- 直接多输出模型:修改模型,让其一次性输出未来多个时间点的预测值。这对模型能力要求较高。
- 递归预测:用模型预测t+1时刻的值,然后将这个预测值作为
lag_1h特征的一部分,再去预测t+2时刻,如此递归进行。这种方法误差会逐步累积。
下表对比了两种方法:
| 特性 | 直接多输出预测 | 递归预测 |
|---|---|---|
| 实现复杂度 | 较高,需修改模型结构 | 较低,复用单步模型 |
| 预测速度 | 快,一次预测所有未来点 | 慢,需逐步迭代 |
| 误差累积 | 无,各步预测独立 | 有,后步误差受前步影响 |
| 长期预测精度 | 相对更稳定 | 随时间推移可能下降较快 |
对于初学者,我建议先从递归预测入手,因为它实现简单。下面是一个递归预测未来24小时负荷的示例函数:
def recursive_forecast(model, last_known_data, feature_columns, steps=24):
"""
递归预测未来多步。
model: 训练好的单步预测模型
last_known_data: 一个包含最新特征值的DataFrame(一行)
feature_columns: 模型训练时使用的特征列名列表
steps: 要预测的未来步数
"""
predictions = []
current_features = last_known_data.copy()
for i in range(steps):
# 预测下一步
next_pred = model.predict(current_features[feature_columns].values.reshape(1, -1))[0]
predictions.append(next_pred)
# 为下一步预测更新特征(这里需要根据你的特征构造逻辑来更新)
# 例如,更新滞后特征:将预测值赋给 lag_1h,原来的 lag_1h 移到 lag_2h,以此类推
# 同时,时间特征(如hour)需要根据预测的步数进行更新
# 这是一个简化的示例,实际更新逻辑更复杂
# current_features['lag_1h'] = next_pred
# current_features['hour'] = (current_features['hour'] + 1) % 24
# ... 更新所有相关的滞后和滚动特征
# 注意:这里省略了复杂的特征更新代码,因为它高度依赖于你的特征工程细节。
# 你需要编写一个函数,根据新的预测时间和预测值,生成下一时刻完整的特征向量。
return np.array(predictions)
# 提示:在实际实现中,你需要一个独立的函数来根据新的时间戳和已知/预测的负荷值,动态生成符合模型输入格式的特征向量。
4.3 模型部署与监控简易思路
模型训练完成后,可以保存下来供后续使用。
import joblib
# 保存模型
joblib.dump(final_model, 'xgboost_power_forecast_v1.pkl')
# 保存特征列名(预测时必须要知道特征的顺序)
import json
with open('feature_columns.json', 'w') as f:
json.dump(feature_columns, f)
print("模型和特征列表已保存。")
在生产环境中,你需要定期用新数据重新训练模型(模型再训练),以应对用电模式随时间可能发生的变化(概念漂移)。可以设置一个自动化脚本,每周或每月用最近N个月的数据重新训练一次。同时,监控模型在最新数据上的预测误差(模型性能监控),如果误差持续增大,就是触发重新训练或告警的信号。
5. 避坑指南与性能提升方向
踩过不少坑后,我总结了几点经验,可能帮你节省大量时间。
- 特征泄露:这是时间序列预测中最常见的错误。确保你在构造任何滚动统计特征(如过去24小时均值)时,只使用了该时间点之前的信息。
pandas的.rolling()函数默认是“向右看”的,要小心。使用.shift(1)来确保绝对安全。 - 评估指标选择:不要只看RMSE。MAE对异常值不那么敏感,MAPE能让业务方直观理解误差水平。同时,绘制预测误差的时间序列图,看看误差是否在某些特定时间段(如节假日、极端天气)系统性增大。
- 外部数据融合:如果有可能,引入天气数据(温度、湿度、天气类型)和日历信息(精确的节假日、学校假期、重大事件)作为特征,对提升预测精度,尤其是对极端情况的预测能力,有巨大帮助。
- 模型集成:XGBoost很强,但并非万能。可以尝试将XGBoost与LightGBM、CatBoost的结果进行加权平均,或者使用Stacking等集成方法,往往能获得更稳定、更精准的预测。
- 残差分析:训练完成后,分析预测残差(真实值-预测值)。如果残差不是随机分布,而是呈现出明显的模式(如周期性),说明模型还有未捕捉到的信息,需要回头检查特征工程。
最后,别忘了,所有机器学习项目都是一个迭代过程。从基线模型开始,逐步添加特征、调整参数、尝试新模型。每次改动只做一个,并严谨地评估其效果。电力负荷预测是一个既有挑战又非常有成就感的领域,看到自己构建的模型能够相对准确地描绘出未来的用电曲线,那种感觉非常棒。希望这篇详尽的指南能为你提供一个坚实的起点。代码都在这里了,剩下的就是动手实验,祝你成功。
211



被折叠的 条评论
为什么被折叠?



