写在前面:在Task2的上分过程中,我同样使用时间序列挖掘,然而分数不升反降,只得到13394。但是这是我第一次参与AI的竞赛,我能够读懂题目以及task2笔记的引导点,想出构建时间相关的特征,已经觉得很高兴了。接下来是Task3给出解法的代码分析——
Step1:导包+读数据
跟之前Task1一致的部分不再赘述,设置绘图风格和参数是我觉得可以关注的:
warnings.filterwarnings('ignore')
plt.style.use('ggplot')
plt.rcParams['font.sans-serif'] = ["WenQuanYi Micro Hei", 'SimHei']
plt.rcParams['axes.unicode_minus'] = False
-
warnings.filterwarnings('ignore'):忽略警告信息,避免输出过多警告 -
plt.style.use('ggplot'):设置Matplotlib绘图的风格为ggplot -
plt.rcParams['font.sans-serif']和plt.rcParams['axes.unicode_minus']:设置Matplotlib的字体以支持中文显示,并使用正常的 ASCII 字符-来表示负号,而不是 Unicode 格式的负号
Step2:特征工程
这一步做的是从电价数据的时间索引中提取特征,并将其添加到训练数据中,以增强模型的特征集。
下面这些特征都是可以直接从timestamp中得出的。
# 将电价数据的时间索引信息添加到训练数据中
# 提取时间索引的小时信息,并添加到训练数据中,创建 "hour" 列
train_data["hour"] = electricity_price.index.hour
# 提取时间索引的日期信息,并添加到训练数据中,创建 "day" 列
train_data["day"] = electricity_price.index.day
# 提取时间索引的月份信息,并添加到训练数据中,创建 "month" 列
train_data["month"] = electricity_price.index.month
# 提取时间索引的年份信息,并添加到训练数据中,创建 "year" 列
train_data["year"] = electricity_price.index.year
# 提取时间索引的星期信息,并添加到训练数据中,创建 "weekday" 列
train_data["weekday"] = electricity_price.index.weekday
# 提取时间索引的季度信息,并添加到训练数据中,创建 "quarter" 列
train_data["quarter"] = electricity_price.index.quarter
下面这些特征灵感来源Task2的EDA图,1-5月和9-12月是大风期,也就是说除了光伏发电和火电,风力发电在这些月份竞争的优势变大。这个特征可能会引起火电电价变化(但是后面并没有用到)。10-15点是一天之中电价较低的时期。
# 根据月份信息,判断是否为风季(1-5月和9-12月),创建布尔型 "is_windy_season" 列
train_data["is_windy_season"] = electricity_price.index.month.isin([1, 2, 3, 4, 5, 9, 10, 11, 12])
# 根据小时信息,判断是否为低谷时段(10-15点),创建布尔型 "is_valley" 列
train_data["is_valley"] = electricity_price.index.hour.isin([10, 11, 12, 13, 14, 15])
下面这段代码在对数据集中的时间特征进行独热编码(One-Hot Encoding),以便将这些类别型特征转换为数值型特征,从而使得机器学习模型能够更好地处理和理解这些数据。
# 对时间特征进行独热编码(One-Hot Encoding),删除第一列以避免多重共线性
train_data = pd.get_dummies(
data=train_data, # 需要进行独热编码的 DataFrame
columns=["hour", "day", "month", "year", "weekday"], # 需要独热编码的列
drop_first=True # 删除第一列以避免多重共线性
)
Step3:节假日特征
灵感同样来源Task2的EDA图,我们曾经得出负电价的出现与节假日相关的结论。为了找出这些节假日,其实可以直接用python的holidays库,是为了提高准确性才手动构造。
def generate_holiday_dates(start_dates, duration):
"""
生成一系列节假日日期列表。
参数:
start_dates (list): 节假日开始日期的列表,格式为字符串,例如 ["2022-01-31", "2023-01-21"]。
duration (int): 每个节假日的持续天数。
返回:
list: 包含所有节假日日期的列表。
"""
holidays = [] # 初始化一个空列表,用于存储节假日日期
for start_date in start_dates: # 遍历每个节假日的开始日期
# 生成从 start_date 开始的日期范围,持续时间为 duration 天
holidays.extend(pd.date_range(start=start_date, periods=duration).tolist())
return holidays # 返回所有节假日日期的列表
# 春节的开始日期列表
spring_festival_start_dates = ["2022-01-31", "2023-01-21", "2024-02-10"]
# 劳动节的开始日期列表
labor_start_dates = ["2022-04-30", "2023-04-29"]
# 生成春节的所有日期,持续时间为 7 天
spring_festivals = generate_holiday_dates(spring_festival_start_dates, 7)
# 生成劳动节的所有日期,持续时间为 5 天
labor = generate_holiday_dates(labor_start_dates, 5)
holidays.extend(pd.date_range(start=start_date, periods=duration).tolist())中,pd.date_range 是 Pandas 中生成日期范围的函数。
start=start_date 指定日期范围的开始日期。
periods=duration 指定日期范围的长度,即生成多少个连续的日期。最后将生成的日期范围(pd.date_range 返回的是一个 DatetimeIndex 对象)转换成 Python 的列表(list)。
假设holidays列表原本为空,start_date = '2024-01-01',duration = 5,生成的样例是:
holidays = ['2024-01-01', '2024-01-02', '2024-01-03', '2024-01-04', '2024-01-05']
给训练数据再加两列判断是否是春节,是否是劳动节:。
# 判断训练数据的索引是否在春节日期列表中,生成布尔型列 "is_spring_festival"
train_data["is_spring_festival"] = train_data.index.isin(spring_festivals)
# 判断训练数据的索引是否在劳动节日期列表中,生成布尔型列 "is_labor"
train_data["is_labor"] = train_data.index.isin(labor)
Step4: 构造基于demand的窗口特征
定义函数分别计算时间序列数据的各种统计特征,例如极差、上升和下降次数、上升和下降部分的均值及标准差。
def cal_range(x):
"""
计算极差(最大值和最小值之差)。
参数:
x (pd.Series): 输入的时间序列数据。
返回:
float: 极差值。
示例:
>>> import pandas as pd
>>> x = pd.Series([1, 2, 3, 4, 5])
>>> cal_range(x)
4
"""
return x.max() - x.min()
def increase_num(x):
"""
计算序列中发生增长的次数。
参数:
x (pd.Series): 输入的时间序列数据。
返回:
int: 序列中增长的次数。
示例:
>>> x = pd.Series([1, 2, 3, 2, 4])
>>> increase_num(x)
3
"""
return (x.diff() > 0).sum()
def decrease_num(x):
"""
计算序列中发生下降的次数。
参数:
x (pd.Series): 输入的时间序列数据。
返回:
int: 序列中下降的次数。
示例:
>>> x = pd.Series([1, 2, 1, 3, 2])
>>> decrease_num(x)
2
"""
return (x.diff() < 0).sum()
def increase_mean(x):
"""
计算序列中上升部分的均值。
参数:
x (pd.Series): 输入的时间序列数据。
返回:
float: 序列中上升部分的均值。
示例:
>>> x = pd.Series([1, 2, 3, 2, 4])
>>> diff = x.diff()
>>> diff
0 NaN
1 1.0
2 1.0
3 -1.0
4 2.0
dtype: float64
>>> increase_mean(x)
1.33
"""
diff = x.diff()
return diff[diff > 0].mean()
def decrease_mean(x):
"""
计算序列中下降的均值(取绝对值)。
参数:
x (pd.Series): 输入的时间序列数据。
返回:
float: 序列中下降的均值(绝对值)。
示例:
>>> import pandas as pd
>>> x = pd.Series([4, 3, 5, 2, 6])
>>> decrease_mean(x)
2.0
"""
diff = x.diff()
return diff[diff < 0].abs().mean()
def increase_std(x):
"""
计算序列中上升部分的标准差。
参数:
x (pd.Series): 输入的时间序列数据。
返回:
float: 序列中上升部分的标准差。
示例:
>>> import pandas as pd
>>> x = pd.Series([1, 2, 3, 2, 4])
>>> increase_std(x)
0.5773502691896257
"""
diff = x.diff()
return diff[diff > 0].std()
def decrease_std(x):
"""
计算序列中下降部分的标准差。
参数:
x (pd.Series): 输入的时间序列数据。
返回:
float: 序列中下降部分的标准差。
示例:
>>> import pandas as pd
>>> x = pd.Series([4, 3, 5, 2, 6])
>>> decrease_std(x)
1.4142135623730951
"""
diff = x.diff()
return diff[diff < 0].std()
下面这段代码的主要目的是对 train_data 数据框中的 "demand" 列应用滚动窗口计算各种聚合函数(平均值,标准差等等)的值,并将结果添加到新的列中。
from tqdm import tqdm # 导入 tqdm 库用于显示进度条
# 定义滚动窗口大小的列表
window_sizes = [4, 12, 24]
# 遍历每个窗口大小
with tqdm(window_sizes) as pbar:
for window_size in pbar:
# 定义要应用的聚合函数列表
functions = ["mean", "std", "min", "max", cal_range, increase_num,
decrease_num, increase_mean, decrease_mean, increase_std, decrease_std]
# 遍历每个聚合函数
for func in functions:
# 获取函数名称,如果是字符串则直接使用,否则使用函数的 __name__ 属性
func_name = func if type(func) == str else func.__name__
# 生成新列名,格式为 demand_rolling_{window_size}_{func_name}
column_name = f"demand_rolling_{window_size}_{func_name}"
# 计算滚动窗口的聚合值,并将结果添加到 train_data 中
train_data[column_name] = train_data["demand"].rolling(
window=window_size, # 滚动窗口大小
min_periods=window_size//2, # 最小观测值数
closed="left" # 滚动窗口在左侧闭合
).agg(func) # 应用聚合函数
pbar.set_postfix({"window_size": window_size, "func": func_name})
除了上面这些时序特征,还可以通过下面几个方法:
-
shift方法: 将序列中的值向前或向后移动指定的位数,并填充移位后产生的缺失值 -
diff方法: 计算序列中相邻值之间的差异,即当前值减去前一个值 -
pct_change方法: 计算序列中相邻值的百分比变化,即当前值减去前一个值再除以前一个值
# 添加新的特征列:demand_shift_1,表示将 demand 列中的值向后移动一位
# shift(1) 的结果是当前行的值等于前一行的值,第一行的值为 NaN
train_data["demand_shift_1"] = train_data["demand"].shift(1)
# 添加新的特征列:demand_diff_1,表示 demand 列中相邻值的差
# diff(1) 的结果是当前行的值减去前一行的值,第一行的值为 NaN
train_data["demand_diff_1"] = train_data["demand"].diff(1)
# 添加新的特征列:demand_pct_1,表示 demand 列中相邻值的百分比变化
# pct_change(1) 的结果是当前行的值减去前一行的值再除以前一行的值,第一行的值为 NaN
train_data["demand_pct_1"] = train_data["demand"].pct_change(1)
从 train_data 数据框中创建训练集和测试集的特征数据 (X) 以及目标数据 (y)。
在创建训练集特征数据 X_train时,从 train_data 中选择前 train_length 行,去除 "price" 列,因为 "price" 列是目标变量,而特征数据不包含目标变量。然后先用bfill 方法向后填充缺失值,再用ffill 方法向前填充剩余的缺失值。创建测试集特征数据 X_test的方法同理。
但是创建训练集目标数据 y_train时,由于"price" 是目标变量,从 train_data 中选择前 train_length 行,只保留 "price" 列。
# 从 train_data 中创建训练集和测试集特征数据 (X) 和目标数据 (y)
# 创建训练集特征数据 X_train
# 1. 从 train_data 中选择前 train_length 行,去除 "price" 列
# 2. 使用 bfill 方法向后填充缺失值
# 3. 使用 ffill 方法向前填充缺失值
X_train = train_data.iloc[:train_length].drop(columns=["price"]).bfill().ffill()
# 创建测试集特征数据 X_test
X_test = train_data.iloc[train_length:].drop(columns=["price"]).bfill().ffill()
# 创建训练集目标数据 y_train
y_train = train_data.iloc[:train_length][["price"]]
由于在数据科学比赛中常用树模型和线性模型,因此使用训练集数据训练了一个 LightGBM (树模型)和一个线性回归模型,无超参数调优。LightGBM 模型使用所有特征进行训练和预测,而线性回归模型仅使用 demand 特征进行训练和预测,最后对测试集数据进行预测,分别得到两个模型的预测结果。
还可以尝试其他模型例如:
- 基于深度学习的方法:LSTM、NBeats、Transformer
- 基于时序模型的方法:ARIMA、Prophet、VARMAX
- 其他线性模型:Ridge、ElasticNet、Lasso
- 其他树模型:CatBoost、XGBoost、LightGBM
from sklearn.linear_model import LinearRegression
from lightgbm import LGBMRegressor
# 创建 LGBMRegressor 模型对象,设置参数
# num_leaves:树的叶子数,控制模型复杂度
# n_estimators:迭代次数,即树的数量
# verbose:日志信息级别,-1 表示不输出日志信息
lgb_model = LGBMRegressor(num_leaves=2**5-1, n_estimators=300, verbose=-1)
# 创建线性回归模型对象
linear_model = LinearRegression()
# 使用训练集数据训练 LGBMRegressor 模型
# X_train:训练集特征数据
# y_train:训练集目标数据
lgb_model.fit(X_train, y_train)
# 使用训练集数据中的 "demand" 特征训练线性回归模型
# X_train[["demand"]]:训练集特征数据中仅包含 "demand" 列
# y_train:训练集目标数据
linear_model.fit(X_train[["demand"]], y_train)
# 使用训练好的 LGBMRegressor 模型预测测试集特征数据
# X_test:测试集特征数据
# 返回预测的目标值
lgb_pred = lgb_model.predict(X_test)
# 使用训练好的线性回归模型预测测试集特征数据中的 "demand" 列
# X_test[["demand"]]:测试集特征数据中仅包含 "demand" 列
# 返回预测的目标值,并将结果展平为一维数组
linear_pred = linear_model.predict(X_test[["demand"]]).flatten()
Step5:模型融合
计算树模型预测结果 lgb_pred 和线性模型预测结果 linear_pred 的均值,通过将两种模型的预测结果进行平均,可以融合两者的优点,减少单个模型可能存在的偏差。但是考虑到Task2中已经分析出,火电整体价格在未来会呈现下降趋势,因此对预测结果进行一个小幅度的下调(乘以0.95),以符合这种趋势。
# 简单求均值
y_pred = (lgb_pred+linear_pred)/2
y_pred *= 0.95 # 进行少量修正
提交代码。
sample_submit["clearing price (CNY/MWh)"] = y_pred
sample_submit.head()
sample_submit.to_csv("submit.csv", index=False)