再见One-Hot!时间序列特征循环编码火了!
昨天晚上十一点多,在公司楼下拿着杯凉掉的咖啡,我跟我们组那个小李在那儿吐槽:“为啥我这个时间序列模型,明明特征都加满了,就是学不明白凌晨两点和下午两点的区别啊?”后来一想,问题就卡在那个…One-Hot 上面。
说人话一点:如果你做的是带时间的任务——比如流量预测、订单量预测、用户活跃度这种——时间这几个特征:小时、星期几、月份,其实都是“绕圈圈”的特征,结果很多人(包括以前的我)上来就一顿 One-Hot,搞得模型完全不懂什么叫“循环”。
假设你有个 hour 特征,范围是 0~23。最经典的做法:
import pandas as pddf['hour'] = df['timestamp'].dt.hour
hour_dummies = pd.get_dummies(df['hour'], prefix='hour')
df = pd.concat([df, hour_dummies], axis=1)
看着很标准对吧,面试也这么写。但你仔细想一下几个坑:
维度一下变成 24 维,特征多到离谱;
更关键的:在 One-Hot 眼里,0 点和 23 点完全没有关系。
23 点的 One-Hot 是 [0,0,...,1]0 点的 One-Hot 是 [1,0,...,0]它俩本来在钟表上是挨着的,结果在向量空间里被拉到老远。
所以模型会觉得:23 点离 0 点的“距离”,跟离中午 12 点差不多。这就离谱了,怪不得学不出“半夜”和“清晨”的连续变化。
之前写数据库性能那篇我就唠叨过,很多“看起来没啥”的小设计,最后都能影响性能,特征这块也是一样
那循环编码是啥意思?你就想象一下,你把“小时”这个东西,不再想成一条直线上的 0~23,而是掰成一个圆。
0 点在圆的最上面; 6 点在右边; 12 点在下面; 18 点在左边; 23 点和 0 点,绕一圈后又挨一块了。
数学上呢,就是给每个“小时”算一个角度 θ,再用 sin / cos 把它投到圆上:
[ \theta = 2\pi \cdot \frac{\text{hour}}{24} ]
[ x = \sin(\theta), \quad y = \cos(\theta) ]
用 Python 写就是这样,很短一段:
import numpy as npdefcyc_encode(value, period):
radians = 2 * np.pi * value / period
return np.sin(radians), np.cos(radians)
# 举个例子:小时特征
for h in [0, 6, 12, 18, 23]:
s, c = cyc_encode(h, 24)
print(h, round(s, 3), round(c, 3))
你会看到:
0 点大概是 (0.0, 1.0)6 点是 (1.0, 0.0)12 点是 (0.0, -1.0)18 点是 (-1.0, 0.0)23 点的坐标会非常靠近 0 点那个坐标
这就牛了:23 点和 0 点在向量空间里真的挨近了,模型能自然学到“快跨天了”的那种连续性,而不是被二进制切成 24 块互相不认识。
实战一点,说说时间序列里到底咋用。
假设你有一坨带时间戳的订单数据:
import pandas as pddf = pd.read_csv('orders.csv', parse_dates=['ts'])
df['hour'] = df['ts'].dt.hour
df['dow'] = df['ts'].dt.dayofweek # 0-6,周一到周日
df['month'] = df['ts'].dt.month # 1-12
现在别急着 get_dummies,我们给它来一波循环编码:
defadd_cyc_features(df, col, period, prefix):
s, c = cyc_encode(df[col].values, period)
df[f'{prefix}_sin'] = s
df[f'{prefix}_cos'] = c
return dfdf = add_cyc_features(df, 'hour', 24, 'hour')
df = add_cyc_features(df, 'dow', 7, 'dow')
df = add_cyc_features(df, 'month', 12, 'month')
这样搞完你会发现:
每个循环特征只多了 2 列(sin / cos),维度巨小; 模型自动知道“周日”和“周一”是挨着的,“12 月”和“1 月”是挨着的; 不管你是喂给 XGBoost、RandomForest 还是 LSTM、Transformer,都挺友好。
随手给个 sklearn 的小例子,方便你拷过去试:
from sklearn.ensemble import RandomForestRegressorfeature_cols = [
'hour_sin', 'hour_cos',
'dow_sin', 'dow_cos',
'month_sin', 'month_cos'
]
X = df[feature_cols]
y = df['target'] # 比如某个时间点的订单量
model = RandomForestRegressor(n_estimators=200, random_state=42)
model.fit(X, y)
很多同学第一次把 One-Hot 换成这种循环编码,啥都不调,就能看到指标有一丢丢肉眼可见的提升,尤其是在那种“日夜周期特别强”的业务里。
当然也别一上头,看到 sin / cos 就满表乱加,几个小坑提前说下:
啥都别瞎循环像“用户 ID”“商品 ID”这种纯编号,完全没周期意义,就别上这个了,不然相邻 ID 被你强行拉到一个圆上,反而在添乱。
周期要选对
dayofweek就是 7,month就是 12,这种很好理解。 有些场景会有“自定义周期”,比如一台机器 48 分钟一个循环,那你周期就用 48 就行。可以多周期叠加比如你既用
hour(24 小时周期),又用 “分钟里第几秒”(60 秒周期),相当于给模型一层一层的“频率信息”,这块在做复杂时间序列、信号的时候挺香。别忘了跟其他特征混着用循环编码解决的是“时间在绕圈”这个问题,但你原来那些趋势特征、滑动窗口统计(7 日均值、近 3 天最大值之类的)照样要算。
反正我现在新开一个时间序列项目,脑子里自带一条小 checklist:
有没有啥“绕圈圈”的特征?小时 / 星期 / 月份 / 自定义周期… 能不能先上个 sin / cos,看一眼模型对周期的感知怎么样? 实在不行,再考虑更重的方案,比如 RNN/Transformer 里直接喂时间戳做 embedding 等等。
One-Hot 不是不能用,只是遇到这类时间特征的时候,真有点像拿锤子拧螺丝,能拧,但费劲,还容易把螺丝帽拧废。
行了,今天就唠到这儿,我先去把我们组那个老项目的 One-Hot 全翻一遍,不然回头又被运营追着问“为啥凌晨三点的预测老不准”…
虎哥作为一名老码农,整理了全网最全《python高级架构师资料合集》,总量高达650GB