做过电力负荷预测或者交通预测朋友,大概率都处理过时间特征。这里最直接的做法通常是把时间(比如分钟或小时)直接扔进模型里。这看起来逻辑自洽,但存在这一个大坑,就是“午夜悖论”。
比如说你的模型面对两个时间点:23:59(一天的第1439分钟) 和 00:01(一天的第1分钟)。在我们的认知里,这俩只差两分钟,但在模型的逻辑里1439 和 1 可是不一样的。大多数机器学习算法(线性回归、KNN、SVM 甚至神经网络)在处理数值时,默认遵循线性逻辑:数值越大,代表的量级越高。它们理解不了“时间是循环的”这个概念。对它们来说午夜不是终点回到起点的闭环,而是一个断崖。
这就是为什么你加了时间特征,模型却在日期变更线附近表现拉胯的根本原因。
传统编码方式的局限性
处理时间特征,最常见的路数无非两种,但这两种都有硬伤。
整数编码(Integer Encoding)
把 0 到 23 点编码成数字 0-23。这就人为制造了一个断层:23 到 0 的跳跃,被模型视作全天最大的波动。但实际上,晚上 11 点到午夜的变化,跟晚上 9 点到 10 点有什么本质区别吗?完全没有。
下面是这种线性模式下,时间特征在数据层面的表现。
# Generate data date_today = pd.to_datetime('today').normalize() datetime_24_hours = pd.date_range(start=date_today, periods=24, freq='h') df = pd.DataFrame({'dt': datetime_24_hours}) df['hour'] = df['dt'].dt.hour # Calculate Sin and Cosine df["hour_sin"] = np.sin(2 * np.pi * df["hour"] / 24) df["hour_cos"] = np.cos(2 * np.pi * df["hour"] / 24) # Plot the Hours in Linear mode plt.figure(figsize=(15, 5)) plt.plot(df['hour'], [1]*24, linewidth=3) plt.title('Hours in Linear Mode') plt.xlabel('Hour') plt.xticks(np.arange(0, 24, 1)) plt.ylabel('Value') plt.show()线性模式下的小时表示。
独热编码(One-hot Encoding)
既然连续数字有问题,那拆成 24 个独立的列呢?断层是没了但丢失了更重要的东西:邻近性(Proximity)。在独热编码下,凌晨 2 点和 3 点的距离,跟它和晚上 10 点的距离是一样的。模型失去了“时间相邻”这个上下文信息,更别提这会让特征维度爆炸,树模型处理起来效率低,线性模型跑起来也费劲。
解决方案:三角函数映射(Trigonometric Mapping)
解决这个问题的核心在于思维视角的转换:不要把时间看作一条直线,而要看作一个圆。
24小时是一个闭环,我们的编码方式也得闭环。把每一个小时想象成圆周上均匀分布的点,要确定圆上一个点的位置单靠一个数值是不够的,我们需要两个坐标:xand y
这就是正弦(Sine)和余弦(Cosine)发挥作用的地方。
几何原理
圆周上的任意角度都可以通过正弦和余弦映射到一个唯一的坐标点。这种映射赋予了模型一个平滑、连续的时间表示。
plt.figure(figsize=(5, 5)) plt.scatter(df['hour_sin'], df['hour_cos'], linewidth=3) plt.title('Hours in Cyclical Mode') plt.xlabel('Hour')经过正弦和余弦转换后的循环模式。
计算公式非常简单:
2 * π * hour / 24:先把小时数值转化成弧度角度。在这个体系下,午夜和晚上 11 点的角度非常接近,通过
sin和
cos将角度投影到两个坐标轴上。
这两个值结合在一起唯一确定了当前的小时,23:00 和 00:00 在特征空间里的距离就被拉得很近了,这正是我们想要的效果。
这套逻辑同样适用于分钟、星期、月份等任何具有周期性的特征。
代码实战
我们拿 UCI 的Appliances Energy Prediction数据集来跑个对比实验。模型选用随机森林回归器(Random Forest Regressor)。
Candanedo, L. (2017). Appliances Energy Prediction [Dataset]. UCI Machine Learning Repository. https://doi.org/10.24432/C5VC8G. Creative Commons 4.0 License.
# Imports from sklearn.ensemble import RandomForestRegressor from sklearn.model_selection import train_test_split from sklearn.metrics import root_mean_squared_error from ucimlrepo import fetch_ucirepo获取数据:
# fetch dataset appliances_energy_prediction = fetch_ucirepo(id=374) # data (as pandas dataframes) X = appliances_energy_prediction.data.features y = appliances_energy_prediction.data.targets # To Pandas df = pd.concat([X, y], axis=1) df['date'] = df['date'].apply(lambda x: x[:10] + ' ' + x[11:]) df['date'] = pd.to_datetime(df['date']) df['month'] = df['date'].dt.month df['day'] = df['date'].dt.day df['hour'] = df['date'].dt.hour df.head(3)先建立一个基准模型(Baseline),使用未处理的线性时间特征。
# X and y # X = df.drop(['Appliances', 'rv1', 'rv2', 'date'], axis=1) X = df[['hour', 'day', 'T1', 'RH_1', 'T_out', 'Press_mm_hg', 'RH_out', 'Windspeed', 'Visibility', 'Tdewpoint']] y = df['Appliances'] # Train Test Split X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42) # Fit the model lr = RandomForestRegressor().fit(X_train, y_train) # Score print(f'Score: {lr.score(X_train, y_train)}') # Test RMSE y_pred = lr.predict(X_test) rmse = root_mean_squared_error(y_test, y_pred) print(f'RMSE: {rmse}')基准结果如下:
Score: 0.9395797670166536 RMSE: 63.60964667197874接下来我们对
hour和
day进行循环编码,替换掉原来的线性特征然后重新训练模型。
# Add cyclical hours sin and cosine df['hour_sin'] = np.sin(2 * np.pi * df['hour'] / 24) df['hour_cos'] = np.cos(2 * np.pi * df['hour'] / 24) df['day_sin'] = np.sin(2 * np.pi * df['day'] / 31) df['day_cos'] = np.cos(2 * np.pi * df['day'] / 31) # X and y X = df[['hour_sin', 'hour_cos', 'day_sin', 'day_cos','T1', 'RH_1', 'T_out', 'Press_mm_hg', 'RH_out', 'Windspeed', 'Visibility', 'Tdewpoint']] y = df['Appliances'] # Train Test Split X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42) # Fit the model lr_cycle = RandomForestRegressor().fit(X_train, y_train) # Score print(f'Score: {lr_cycle.score(X_train, y_train)}') # Test RMSE y_pred = lr_cycle.predict(X_test) rmse = root_mean_squared_error(y_test, y_pred) print(f'RMSE: {rmse}')结果显示,Score 提升了约 1%,RMSE 下降了 1 个点。
Score: 0.9416365489096074 RMSE: 62.87008070927842看着提升不大?这只是一个简单的 Toy Example,也没做任何精细的数据清洗。但这里的提升完全来自于正弦和余弦变换带来的特征表达能力的增强。
本质上这让模型“看懂”了现实世界中电力需求的连续性,它并不会因为时钟跳过 0 点就突然归零。
为什么 Sin 和 Cos 缺一不可?
很多人可能会想,只用 Sin 这一列行不行?还能省点特征维度。答案是不行。这会破坏对称性。在一个 24 小时的圆周上,早上 6 点和晚上 6 点的 Sine 值可能是一样的。如果只给模型一个值,它就会混淆这两个截然不同的时间段(比如早高峰和晚高峰)。必须同时使用 Sin 和 Cos就像定位必须要有经度和纬度一样。只有这样圆上的每一个小时才能拥有唯一的“特征指纹”。
实际应用中的收益
这套方法在不同模型下的收益是不一样的:
- 基于距离的模型(KNN, SVMs):这是最大的受益者。循环编码消除了边界上的伪“长距离”,让数据点之间的距离计算回归真实。
- 神经网络(Neural Networks):平滑的特征空间有助于网络更快的收敛和更稳定的训练表现,消除了午夜那种剧烈的数值跳变。
- 树模型(Tree-based models):虽然像 XGBoost 或 LightGBM 这种强力模型最终也能通过不断分裂学到这种模式,但提供循环编码特征相当于给了它们一个极佳的先验知识(Inductive Bias),在追求极致性能和解释性时非常有用。
适用场景
使用这套方法的判断标准很简单,问自己一个问题:**这个特征是循环往复的吗?**如果是,那就试试。常见的例子包括:
- 一天中的小时(0-23)
- 一周中的星期(1-7)
- 一年中的月份(1-12)
- 风向(0-360度)
总结
时间在数据科学里不应该只是一个冰冷的数字,它本质上是圆周上的坐标。如果你执意把它当直线处理,模型在周期边界处跌倒是迟早的事。使用正弦和余弦进行循环编码,是一种优雅且低成本的修正手段。它保留了数据的邻近性,消除了人工伪影,能让模型学得更快、更准。下次如果你的模型预测曲线在日期交界处出现诡异的跳变,不妨试试这个方法。
https://avoid.overfit.cn/post/5fea3ffcb7ac4b27a3a0d7bb55b9bd39
作者:Gustavo Santos