时间序列特征工程:因果性、滚动窗口与线上一致性 做销量预测那阵子我踩过一个到现在还记得很清楚的坑线下验证集指标漂亮得不像话模型一上线周一的表现直接掉到基线以下。折腾了整整两天把特征一行行拉出来看问题出在一个看着特别无害的滚动均值上——窗口里把当前时刻的真实值也算了进去等于让模型提前看到了答案。那次之后我对时间序列特征的态度就变了它跟普通表格特征不是一回事普通特征拼的是信息量和业务理解时间序列特征拼的第一件事是不撒谎。这篇就围绕“时间序列特征”这件事展开把我这些年做需求预测、流量预测、设备指标预警、用户行为预测时攒下来的东西完整梳理一遍。内容会覆盖时间序列特征到底该怎么分类、每一类怎么算、参数怎么定、验证怎么做、上线之后又该怎么保证一致。适合已经会写 pandas、做过一两个建模项目但一到时序场景就总觉得模型“虚高”或者“死活提不上去”的同学也适合做数据开发、要把特征逻辑沉淀成公共能力的同学。代码我会给到能直接跑的程度参数也会解释为什么这么选而不是甩一堆数字让你自己猜。1. 时间序列特征为什么和普通表格特征不是一回事1.1 第一个分水岭因果性和时间方向感普通表格数据里行和行之间是独立的你随便打乱顺序、随便做交叉特征都不会改变每一行本身的含义。时间序列不一样行和行之间有严格先后关系第 t 行能用的信息只能来自 t 之前预测类任务里还要再往前让出预测步长。这条约束听起来像废话但实际写代码的时候它破得非常隐蔽。举个最常见的例子。假设你要预测明天某门店的销量手上有过去两年的日销量。很多人会这么写df[roll_7_mean] df[sales].rolling(7).mean() df[lag_1] df[sales].shift(1)lag_1是对的它取的是前一天。但roll_7_mean是错的——rolling(7).mean()在第 t 行算的是第 t-6 到第 t 天这七天的均值包含了第 t 天的真实销量。如果第 t 天是训练样本的标签那这个特征就是标签的平滑版如果第 t 天是预测日那这个特征在预测时根本拿不到。两种情况都会让线下指标虚高。正确写法只有两种选一种# 写法一先 shift 再 rolling窗口里永远不含当前点 df[roll_7_mean] df[sales].shift(1).rolling(7).mean() # 写法二rolling 时关掉右端点 df[roll_7_mean] df[sales].rolling(7, closedleft).mean()我个人更倾向写法一原因是shift的数值可以直接参数化后面做多步预测的时候只要把shift(1)改成shift(h)就行逻辑上是连续的而closedleft和 horizon 的耦合关系不明显换个同学接手容易改错。注意shift和rolling的顺序写反是时序特征里最常见也最贵的一个 bug。它不会报错不会给你任何提示只会让指标好看。养成习惯任何涉及当前行数值的窗口计算先问一句“我 shift 了吗”。还有一类更隐蔽的因果问题出现在“未来才知道的信息”上。比如做用户流失预测有人把“用户最后一次登录距今的天数”当特征这个特征在训练集里是完整的但预测时点是固定的这个“距今”到底是距今天还是距预测时点如果口径不统一训练和推理就是两套数据。这类坑我后面第 5 节会专门拆。1.2 把时间序列特征拆成六类思路立刻清楚我习惯把时序特征按“信息从哪来”分成六类这个分类不是为了写论文好看而是因为每一类的坑点、参数选择、验证方式都不一样混在一起想很容易乱。类别典型特征信息源主要风险时间戳派生星期、小时、是否月末、距月末天数时间列本身周期编码方式不当、业务日历不对齐滞后特征lag1、lag7、lag28目标的历史值shift 方向写错、冷启动缺失滚动窗口近 7 天均值/标准差/分位数目标或协变量的历史区间窗口含当前点、min_periods 设置差分与变化率同比、环比、一阶差分两个历史点之间的差除零、量纲不一致周期与频域sin/cos 项、傅里叶展开时间列的周期结构阶数过多导致过拟合交叉与分组统计品类×星期的历史均值分组后的历史聚合分组过细导致样本不足、泄漏这六类里前两类几乎人人都做第三类是真正拉开差距的地方第四类往往被低估第五类经常被过度使用第六类是最容易埋雷的。下面按我的实际使用频率和踩坑密度来展开。1.3 先定预测粒度再谈特征顺序不能反有个事我必须提前说特征设计不是从数据开始的是从预测任务的定义开始的。同样是“预测销量”日粒度提前 1 天预测和日粒度提前 14 天预测特征几乎是两套东西。原因是滞后特征的有效起点被预测步长卡死了。如果你要提前 14 天预测那么lag_1到lag_13在预测时点全都拿不到因为预测时点往前 14 天之后的真实值都还没发生你只能用lag_14、lag_15这些。很多人做完模型才发现自己用了一堆预测时不可得的特征返工成本极高。我一般的做法是在动手写特征函数之前先在纸上写清楚四件事预测粒度小时 / 日 / 周预测步长 horizon提前几步预测时点能拿到的最新真实值是哪一天这个决定了最小可用 lag评估口径是整体误差还是分位数误差还是分组误差这四条定下来特征池的边界就清楚了。剩下的是在这个边界内做加法而不是做完再删。2. 时间戳派生特征最便宜的一类也是错得最随意的一类2.1 基础日历特征怎么拆才有信息量时间戳派生特征成本极低一行dt.dayofweek就能出一列所以很多人就真的只写这一列。但原始的星期序号 0-6 对树模型来说是有序数值模型会默认 6 比 0 “大”这个假设在业务上完全不成立。我的常规做法是把一个时间字段拆成三层第一层是原始分量年、月、日、小时、分钟、秒。这些保留着因为后面做聚合和分组要用。第二层是离散标记是否周末、是否月初1-3 号、是否月末倒数 3 天、是否季度首尾、是否工作日。这些是布尔特征树模型好切。第三层是距离型特征距离月末还有几天、距离月初过了几天、距离下一个法定长假还有几天。这层是我认为最被低估的因为它把“位置”变成了“距离”连续且有序对模型的友好度远高于“是否月末”这种硬切分。举个具体场景某零售业务线的月度促销集中在月末三天销量在月末前会有一个爬坡。用“是否月末”这个布尔特征模型只能学到“月末那几天高”学不到爬坡过程用“距离月末天数”这个连续特征模型能学到“越接近月末越高”这个趋势。后者在预测月末当天时特征值是 0和训练时完全对齐泛化更稳。import pandas as pd import numpy as np def calendar_features(df, ts_col): t df[ts_col] out pd.DataFrame(indexdf.index) out[month] t.dt.month out[day] t.dt.day out[hour] t.dt.hour out[dow] t.dt.dayofweek out[is_weekend] (t.dt.dayofweek 5).astype(int) out[is_month_start] (t.dt.day 3).astype(int) out[is_month_end] (t.dt.day t.dt.days_in_month - 2).astype(int) # 距离型特征 out[days_to_month_end] (t.dt.days_in_month - t.dt.day).astype(int) out[days_from_month_start] (t.dt.day - 1).astype(int) return out这里有个小细节t.dt.days_in_month这个属性在不同 pandas 版本里行为一致可以放心用但如果你的数据是月粒度而不是日粒度这一整层就没意义了别硬套。2.2 周期编码sin/cos 什么时候真的有用星期、小时、月份这类字段本质上都是“环形”的——周日和周一挨着23 点和 0 点挨着。用原始序号会切断这种邻近关系。标准的处理是用正余弦编码def cyclical_encode(x, period): return np.sin(2 * np.pi * x / period), np.cos(2 * np.pi * x / period) df[dow_sin], df[dow_cos] cyclical_encode(df[dow], 7) df[hour_sin], df[hour_cos] cyclical_encode(df[hour], 24) df[month_sin], df[month_cos] cyclical_encode(df[month], 12)但我要泼一盆冷水对梯度提升树这类模型sin/cos 编码的收益经常不明显甚至会掉点。原因是树模型切分是按阈值来的sin/cos 把一维信息映射到二维虽然保住了邻近性但也让单次切分能表达的东西变少了。如果你的模型是线性回归、神经网络、或者带时间嵌入的结构sin/cos 的价值才明显。我自己的实际做法是树模型场景优先用“原始序号 离散标记 距离特征”的组合sin/cos 作为备选特征一起丢进去试让模型自己选线性/深度模型场景则默认上 sin/cos。多阶傅里叶项也就是同一个 period 上取 k1,2,3 多组我一般只在数据有明显的双峰周期时才用。比如某出行业务的早晚高峰一天里有两个峰只用 k1 的 sin/cos 描述不了双峰形态得上到 k2。但阶数上到 4 以上我基本没见过正收益反而容易把噪声拟合进去。注意周期长度别硬编码成 365。年周期的真实长度是 365.2422 天如果你的数据跨了好几年用 365 会让相位每年漂一天长期看会错位。要么用 365.25要么干脆把年周期拆成“年内第几天”再用分段特征处理更稳。2.3 节假日和业务日历最难对齐的一块日历特征里真正难的不是星期几是节假日。难在两点一是每年日期不固定二是业务口径和自然日历不一致。我的处理方式是把节假日做成一张独立的日历表字段包括日期、是否节假日、节假日类型、距离节假日的天数正负号表示前后、节假日持续天数。然后按日期左连接主表。这张表必须由业务方确认不能自己拍脑袋。为什么要加“距离节假日的天数”而不是只留“是否节假日”因为业务上的节假日效应是有提前量和滞后量的。某电商业务的年货节节前 7 天流量开始爬坡节后 3 天回落单纯一个“是否节假日”只能标记当天建模时这些前后效应全靠模型自己从其他特征里凑效率很低。加上带符号的距离特征后模型能直接学到“节前第 5 天”这个位置的信息。还有一类是业务日历比如发薪日、结算日、会员日。这类日期在自然日历上看不出规律但对业务指标影响巨大。我一般会单独维护一张business_calendar表和节假日表一样做距离展开。这块的工作量和收益往往是成正比的尤其是做零售和金融方向的同学。3. 滞后与滚动窗口时间序列特征真正的主战场3.1 lag 怎么选对齐周期比数量堆叠更重要滞后特征的选法我见过两种极端一种只写lag_1一种从lag_1写到lag_60。两种都不对。正确的思路是先看自相关再对齐业务周期。做法很简单把目标序列做 ACF自相关函数看哪些滞后阶数上有明显的峰。日粒度数据里lag 7、14、21、28 通常都有峰因为周周期性月粒度数据里 lag 12、24 有峰因为年周期性。这些位置上的 lag 是“信息量大”的位置必选。然后再补业务周期。比如某业务的促销周期是 10 天一轮那 lag 10、20 就值得加进去即使 ACF 上峰不明显业务逻辑上它是有意义的。数量上我一般控制在 8 到 15 个 lag 之间。太少抓不住结构太多会带来严重的共线性——lag_1到lag_7之间相关性往往都在 0.9 以上堆到几十个只会稀释特征重要性还拖慢训练。def lag_features(df, col, lags, id_colNone, time_colNone): out pd.DataFrame(indexdf.index) for l in lags: if id_col: out[f{col}_lag{l}] df.groupby(id_col)[col].shift(l) else: out[f{col}_lag{l}] df[col].shift(l) return out多序列场景比如几千个门店各一条序列必须用groupby().shift()直接df[col].shift()会把上一个门店的值串到下一个门店的第一行去这种错误在数据量大、门店切换频繁的时候极难发现。判断方法很简单如果你的数据是长表格式且按 id 排序一定要 groupby。3.2 滚动窗口窗口长度、min_periods、聚合函数三个参数定生死滚动窗口是时序特征里信息密度最高的一类也是参数最多的一类。我把三个关键参数拆开说。窗口长度不要凭感觉。我的经验规则是——窗口长度取业务周期的整数倍。日粒度取 7、14、28周周期的倍数月粒度取 3、6、12季度和年。再配一个短窗口3 或 7抓近期波动一个长窗口28 或 90抓趋势。三个长度基本够用超过五个长度的边际收益很低。min_periods这个参数决定了序列开头有多少个 NaN。默认是等于窗口长度意味着前 6 行窗口 7全是 NaN。如果你的序列很短或者分组很多、每组序列不长NaN 会占很大比例后面填充又引入偏差。我一般设成max(1, window // 2)让窗口在积累到一半时就开始出值。代价是开头的窗口统计量方差大但对树模型影响可控。聚合函数均值是最基础的但只有均值是不够的。我常用的一套是聚合表达的含义适用场景mean近期水平通用基线std波动幅度异常检测、需求不确定性max / min极值峰值预测、容量规划median抗噪水平存在离群点的业务sum累计量流量、金额类分位数 q90 / q10尾部行为库存、风控skew分布偏斜促销、爆发式增长识别再加两个我自己常用的“衍生统计”# 滚动变异系数衡量稳定性比 std 更好跨量级比较 df[cv_7] df[sales].shift(1).rolling(7, min_periods4).std() / \ (df[sales].shift(1).rolling(7, min_periods4).mean() 1e-6) # 滚动趋势用窗口内的线性斜率表达方向 def rolling_slope(s, window): x np.arange(window) x x - x.mean() denom (x ** 2).sum() return s.rolling(window, min_periodswindow).apply( lambda y: np.dot(x, y - y.mean()) / denom, rawTrue) df[trend_14] rolling_slope(df[sales].shift(1), 14)滚动斜率这个特征我特别推荐。均值和标准差只能告诉你“水平”和“波动”但“方向”是另一维信息尤其在趋势性强的业务里比如用户增长、装机量斜率特征带来的提升经常比再加一个窗口均值明显。代价是rolling().apply()比较慢几百万行以上要考虑用 numba 或者提前做差分近似。注意rolling().apply()在 pandas 里是纯 Python 循环性能很差。如果数据量大先做一次diff()再用rolling().mean()近似斜率误差在业务上通常可以接受速度快一个数量级。3.3 expanding 和 ewm冷启动和近期权重怎么权衡expanding是“从序列开头累加到当前点”适合做历史累积统计。它的价值在于冷启动——新上线的门店、新注册的用户没有 28 天历史滚动窗口全是 NaN但expanding().mean()从第一天就有值虽然只有一两个点方差大。用法上要配合 shiftdf[exp_mean] df.groupby(store_id)[sales].transform( lambda s: s.shift(1).expanding(min_periods3).mean())ewm是指数加权越近的点权重越大。它和滚动窗口的区别在于滚动窗口是硬截断第 8 天前的数据完全不看ewm 是软衰减老数据权重小但不为零。在业务上如果指标有缓慢漂移比如用户结构在变ewm 往往比等权滚动更贴合。参数上span的选择和窗口长度类似取业务周期df[ewm_7] df.groupby(store_id)[sales].transform( lambda s: s.shift(1).ewm(span7, min_periods3).mean())我一般的组合是短窗口用 rolling近期水平要清晰长趋势用 expanding 或 ewm避免长窗口在数据不足时全空。两者不是替代关系是一起用。3.4 差分和变化率相对量特征往往比绝对量更有用绝对量特征有个大问题跨序列不可比。一个日均 10 万的门店和一个日均 1000 的门店绝对销量的分布完全不同模型要花很多容量去学这个量级差异。相对量特征能把这个差异消掉。我常用的四种# 一阶差分绝对变化 df[diff_1] df.groupby(store_id)[sales].diff(1) # 环比相对变化注意除零 df[pct_1] df.groupby(store_id)[sales].pct_change(1) # 同比对周期和上周同一天比 df[yoy_7] df[sales] / (df.groupby(store_id)[sales].shift(7) 1e-6) - 1 # 相对基准当前值 / 近 28 天均值 base df.groupby(store_id)[sales].transform( lambda s: s.shift(1).rolling(28, min_periods14).mean()) df[ratio_base] df[sales] / (base 1e-6)pct_change的除零问题必须处理。如果历史值可能是 0比如某商品某天没销量直接除会出 inf模型训练会崩。加一个小的 epsilon 是最简单的做法但要注意 epsilon 的量级要和数据量级匹配——如果销量都是几千几万加 1e-6 没影响如果销量是 0 到 5 之间的小数值1e-6 就太小了用 1e-3 更稳。ratio_base这个特征我用了很多年它把“绝对水平”和“相对异常”分开了base 特征告诉模型这个店平时卖多少ratio 特征告诉模型今天比平时高还是低。两个一起给模型学起来比只给绝对量轻松得多。4. 一条可复现的特征流水线从原始表到训练矩阵4.1 数据准备与切分切分必须在特征之前这一节我给一条完整的、能跑的流水线。先说切分——切分必须在特征生成之前做或者至少要在时间维度上严格隔离。很多人是先在全量数据上算完特征再切分这样验证集的特征里混入了训练期的统计量属于典型的泄漏。我的做法是先按时间切出 train / valid / test 三段特征生成函数对三段分别执行窗口计算只在各自段内进行。这样验证集的特征虽然少了一些历史因为它看不到训练期的尾部数据但这恰恰模拟了真实预测场景。import pandas as pd import numpy as np # 假设原始数据date, store_id, sales df df.sort_values([store_id, date]).reset_index(dropTrue) cut1 pd.Timestamp(2024-01-01) cut2 pd.Timestamp(2024-04-01) train df[df[date] cut1].copy() valid df[(df[date] cut1) (df[date] cut2)].copy() test df[df[date] cut2].copy()这里有个取舍要说清楚严格隔离会让验证集开头一段的特征是 NaN因为窗口要从验证集自身积累。如果你的验证集只有 3 个月这段 NaN 会吃掉不少样本。折中方案是给验证集留一段“预热期”——取验证集前 28 天只用于积累窗口不参与评估。这个技巧在实践里非常好用代价是要多维护一个时间边界。4.2 特征生成函数把逻辑收成一个可复用的入口我把所有特征逻辑收成一个函数输入原始表输出特征表。这样离线训练、离线回填、在线推理可以共用同一份逻辑避免三套代码对不齐。def build_features(data, target_colsales, id_colstore_id): data data.sort_values([id_col, date]).copy() out data[[id_col, date]].copy() t data[date] # 1) 日历特征 out[dow] t.dt.dayofweek out[is_weekend] (t.dt.dayofweek 5).astype(int) out[days_to_month_end] (t.dt.days_in_month - t.dt.day).astype(int) out[dow_sin] np.sin(2 * np.pi * t.dt.dayofweek / 7) out[dow_cos] np.cos(2 * np.pi * t.dt.dayofweek / 7) g data.groupby(id_col)[target_col] shifted g.shift(1) # 统一先 shift后续全部基于 shifted 计算 # 2) 滞后特征 for l in [1, 2, 7, 14, 21, 28]: out[flag{l}] g.shift(l) # 3) 滚动窗口基于 shift 后的序列保证不含当前点 for w in [7, 14, 28]: mp max(1, w // 2) r shifted.groupby(data[id_col]).rolling(w, min_periodsmp) out[froll{w}_mean] r.mean().reset_index(level0, dropTrue).sort_index() out[froll{w}_std] r.std().reset_index(level0, dropTrue).sort_index() out[froll{w}_max] r.max().reset_index(level0, dropTrue).sort_index() # 4) 变化率 out[diff1] data.groupby(id_col)[target_col].diff(1) out[pct1] data.groupby(id_col)[target_col].pct_change(1).clip(-5, 5) out[yoy7] data[target_col] / (g.shift(7) 1.0) - 1 # 5) 相对基准 base shifted.groupby(data[id_col]).rolling(28, min_periods14).mean() base base.reset_index(level0, dropTrue).sort_index() out[ratio_base] data[target_col] / (base 1e-3) # 6) 冷启动 out[exp_mean] shifted.groupby(data[id_col]).expanding(3).mean() \ .reset_index(level0, dropTrue).sort_index() return out这段代码有两个地方值得拎出来讲。第一个是pct1上的.clip(-5, 5)。变化率特征极容易出现长尾比如某天销量从 1 涨到 100pct 就是 99。这种极端值对树模型的影响没线性模型那么大但仍然会干扰分箱和后续的标准化。截断在 [-5, 5] 是经验值对应“涨 6 倍”和“跌 83%”业务上再夸张的波动基本都能覆盖住。第二个是groupby().rolling()的索引处理。这是 pandas 里非常容易出错的一段groupby().rolling()返回的是多层索引必须reset_index(level0, dropTrue)再sort_index()才能和原表对齐。如果直接赋值很容易出现顺序错乱但行数对得上、不报错的情况后果比报错还严重。我的习惯是每次做完这一步都抽一个 store_id 手工核对前 10 行的值。4.3 组装与验证先看 NaN 比例再看特征分布特征表出来后第一件事不是训练是三个检查。检查一NaN 比例。每个特征列算一下缺失率超过 30% 的要逐个看原因。常见的合理缺失是序列开头的窗口不足不合理的缺失是 groupby 键没对齐、日期不连续导致 shift 错位。检查二日期连续性。shift和rolling都隐含一个假设——行是按时间连续的。如果某门店中间缺了几天数据shift(7)取到的就不是 7 天前而是 7 行前。这个错误在数据补采、节假日停业、门店关店重开的场景里极其常见。标准做法是先把数据按 (id, date) 补齐成完整笛卡尔积再做特征full_idx pd.MultiIndex.from_product( [df[store_id].unique(), pd.date_range(df[date].min(), df[date].max())], names[store_id, date]) df df.set_index([store_id, date]).reindex(full_idx).reset_index() # 补齐后 target 会有 NaN先标记再决定是否填充 df[is_missing] df[sales].isna().astype(int)补齐后有个决策要做缺失的目标值怎么处理。如果是停业填 0 是合理的如果是数据丢失填 0 就是错的。我一般加一个is_missing标记特征然后把缺失值留成 NaN 交给后面的填充策略统一处理。检查三单特征和时间的关系。把某个重要特征比如roll7_mean按时间画出来看它的分布在不同时间段有没有剧烈跳变。如果有说明特征的定义或数据口径在中途改过这种特征上线后大概率出事。4.4 滚动验证判断特征有没有真增益的唯一靠谱方式特征加了一堆怎么知道有没有用单看一次 train/valid 划分的结果非常不可靠因为时序数据波动大一次划分的运气成分很高。我用的是 walk-forward 验证把训练期切成若干个时间窗每次用前面的窗训练、后面的窗验证滚动前进。from sklearn.model_selection import TimeSeriesSplit import lightgbm as lgb def walk_forward_score(X, y, n_splits5): tscv TimeSeriesSplit(n_splitsn_splits) scores [] for tr_idx, va_idx in tscv.split(X): model lgb.LGBMRegressor( n_estimators500, learning_rate0.05, num_leaves31, min_child_samples30, verbose-1) model.fit(X.iloc[tr_idx], y.iloc[tr_idx]) pred model.predict(X.iloc[va_idx]) mae np.mean(np.abs(y.iloc[va_idx] - pred)) scores.append(mae) return np.mean(scores), np.std(scores)评估指标我建议至少看两个一个绝对误差MAE一个相对误差比如 WAPE即总绝对误差除以总实际值。只看 MAE 会被量级大的序列带偏只看相对误差又会忽略绝对影响。两个一起看判断更稳。还有一个经常被忽略的点特征增益要看分组。整体指标提升 1%可能是某几个大门店贡献的中小门店反而变差了。上线后最容易被投诉的就是中小门店。所以验证时按门店规模分几档分档看指标这一步花不了十分钟但能省掉很多线上扯皮。5. 数据泄漏与验证策略翻车密度最高的地方5.1 三类典型泄漏每一类我都踩过第一类窗口泄漏。前面反复说的rolling含当前点是最高频的一种。判断方法很简单把roll7_mean和sales算一下相关系数如果超过 0.95基本可以确定泄漏了——正常的 7 天均值不可能和目标本身这么相关。第二类全局统计泄漏。比如做目标编码时用全量数据算每个品类的均值然后赋给每一行。这个均值里包含了验证期的信息。正确做法是expanding().mean()或者只在训练期上算均值再映射。这类泄漏在线性模型里影响不大但在高基数类别特征上会非常致命因为它几乎是标签的一个平滑版本。第三类口径泄漏。这类最难发现。比如特征名叫“近 30 天平均下单量”但你在离线计算时用的是自然月在线上服务时用的是滑动 30 天又比如“距今天数”在离线时是距数据截止日在线上时是距请求日。两套口径的分布不同模型在线上就会表现异常而且你怎么查训练数据都查不出问题。第三类泄漏的防御手段只有一个特征定义必须写成文档并且离线在线用同一份代码。我后面第 7 节会展开。5.2 三种切分方式对比什么时候用哪个切分方式做法适用场景风险随机切分打乱后按比例分几乎不适用时序任务严重泄漏指标虚高单次时间切分按时间点切一刀快速验证、特征筛选受单次波动影响大滚动切分多折按时间前进最终评估、模型选型计算成本高我的实际用法是特征开发阶段用单次时间切分快速迭代快候选特征定下来后用滚动切分做最终判断稳两者结果差异大的特征要重点看。随机切分我只在一个场景下用——确认“特征有没有信息量”这时候泄漏反而能告诉你上限在哪但它绝对不能用于任何上线决策。还有一个细节验证集和训练集之间最好留一个 gap长度等于预测步长。因为如果 horizon 是 7 天训练集最后 7 天的标签在验证期开始时其实还没发生直接相邻会造成轻微泄漏。留 gap 会损失几天样本但换来的是干净的评估。6. 常见问题排查速查表下面这张表是我这些年积攒的问题清单基本覆盖了时序特征从开发到上线 80% 的异常。现象最可能的原因排查动作处理方式线下指标远高于线上窗口含当前点或全局统计泄漏查所有 rolling 前是否有 shift算特征与标签相关性统一先 shift全局统计改 expanding特征重要性全集中在 lag1目标自相关过强模型偷懒看 lag1 与标签的相关系数拉长 horizon改用差分目标做多步直接预测训练集大量 NaNmin_periods 过严或序列过短统计每列缺失率调低 min_periods对短序列用 expanding特征数爆炸、训练变慢窗口×聚合×列的组合膨胀统计特征总数按重要性剪枝保留增益前 60%某些门店预测总偏分组特征缺失或量级差异大按门店分档看误差加相对量特征ratio_base分档建模节假日特征完全没效果日历表口径与业务不一致抽几个节假日核对标记重新对齐业务日历加距离特征特征分布在时间上漂移业务结构变化、口径变更按月看特征均值用相对量替代绝对量加时间衰减权重线上特征值与离线对不上离线在线计算逻辑不一致双跑同一批样本比对统一特征代码加一致性校验任务补齐日期后指标变差缺失填 0 引入偏差看缺失占比和分布加 is_missing 标记区分停业与丢数表格里每一行我都能对应到一次具体的排查经历其中“补齐日期后指标变差”这条特别值得说。补齐日期本来是好事但如果原始数据里缺失的那几天恰好是停业填 0 是对的如果是数据没采上来填 0 就等于告诉模型“那几天卖得极差”模型会学出一个假的低谷模式。所以补齐之后一定要加is_missing标记让模型自己区分这两种情况同时人工核对缺失日的业务事实。7. 落到工程上一致性比模型精度更重要7.1 别把特征写在训练脚本里我见过太多项目特征是直接写在训练脚本里的几十行 pandas训练完就丢在那里。等到要上线工程同学重新实现一遍两边逻辑必然有偏差。偏差小的表现为线上指标略降偏差大的直接出事故。我的做法是把特征定义抽成配置脚本只负责执行。配置大概长这样FEATURE_SPEC [ {name: lag7, type: lag, col: sales, params: {lag: 7}}, {name: roll7_mean, type: rolling, col: sales, params: {window: 7, agg: mean, min_periods: 3}}, {name: yoy7, type: ratio, params: {lag: 7}}, ]配置化的好处是离线训练、离线回填、在线服务三处读同一份配置执行引擎各自实现但语义一致。更进一步我会加一个“双跑校验”任务每天用离线和在线引擎分别算同一批样本的特征逐列比对差异超过阈值的报警。这个任务在刚上线那两周抓到过好几次问题后面就稳定了。7.2 回填和监控上线后才真正开始回填是个容易被低估的工作。新模型上线历史特征需要按新定义重算一遍。这时候会遇到老问题早期的数据量少滚动窗口全是 NaN。我的处理是分级填充优先用同序列的 expanding 均值其次用同品类同期均值最后用全局均值。三级兜底保证回填出来的特征没有大空洞。监控至少要看三个维度覆盖率每列特征的非空比例环比掉超过 5% 要查分布漂移用 PSI 或简单的分位数比对监控特征分布是否在缓慢移动取值合理性给每列特征设一个业务上合理的上下界超界报警第三项听起来粗糙但极其有效。我遇到过某次上游数据源把一个金额字段的单位从元改成了分特征值直接放大 100 倍模型预测全乱。如果有上下界监控这个问题在当天就能被发现而不是等业务方来问“为什么昨天的预测离谱”。最后分享一个我自己一直在用的小技巧给每个特征建一个“极简版”的替代实现只保留最简单的那一两个聚合用来做故障降级。线上万一某个复杂特征的计算链路挂了可以快速切到极简版保证服务不断精度略降总比无值可用强。这个思路在流量高峰和依赖服务不稳定的时候救过我好几次。至于特征到底要加多少我的经验是一个稳定的时序模型核心特征通常不超过 40 个其中真正贡献增益的可能就十几个。每次想加新特征之前先问自己一句“它补的是哪个维度”——水平、波动、趋势、周期、异常五个维度里如果一个新特征哪个都不沾那它大概率只是在给模型增加噪声。