跳到主要内容

时间序列与滚动前推评估

预测下一季度的销量,需要重现每次预测时真正看得到的数据。把今天下载的完整历史表切成前后两段,仍可能把后来修订的数值放进过去。评估要同时回答两个问题:当时能知道什么,以及用这些信息能预测多远。

数据划分与泄漏给出了通用的信息边界;pandas 日期与时间序列讲解析、重采样与窗口。这里把这些边界落实到连续的预测截点、基准模型和误差计算。

先分清观测、发布与版本​

ALFRED 的说明区分观测期与历史数据版本:过去的经济数据会被修订,ALFRED 保存过去日期可见的版本。它通常在发布后一个工作日内收录新值和修订值;发布日期缺失时还可能采用其他日期。因此,日内预测需要另查实际发布时间和自己的接收时间。

时间或版本回答的问题假设的月度销量例子
观测期活动发生在什么时候?2026 年 1 月
发布时间外界何时能看到这个值?2 月 10 日首次发布 100
数据版本(vintage)某个历史日期能看到哪次估计?3 月 10 日把 1 月值修订为 104
系统接收时间预测系统何时真正拿到它?可能晚于公开发布

在 2 月 28 日做预测,只能使用 1 月的 100。假设 2 月的 90 要到 3 月 12 日才公布,它也不能成为输入。下面用 Python 标准库的 date.fromisoformat解析发布日期,按截点筛选记录,再为每个观测期取当时最新的值。截点指所列日期结束时;例子假定发布后立即收到数据,同一观测期每天最多发布一个版本。若一天内有多个版本,就要用时间戳或明确的发布顺序区分。

from datetime import date

rows = [
("2026-01", "2026-02-10", 100),
("2026-01", "2026-03-10", 104),
("2026-02", "2026-03-12", 90),
]
cutoff = date(2026, 2, 28)
latest = {}
dated_rows = [(period, date.fromisoformat(released), value)
for period, released, value in rows]
for period, released, value in sorted(dated_rows, key=lambda row: row[1]):
if released <= cutoff:
latest[period] = value
print("as_of", cutoff.isoformat(), latest)
assert latest == {"2026-01": 100}

输出:

as_of 2026-02-28 {'2026-01': 100}

实际记录应保留各次发布,先按可用时间过滤,再选版本,不能先取今天的最新值再按观测期过滤。训练时也只能使用截至训练截点已经可得的标签。评估目标究竟是首次发布值还是规定日期的修订值,也要先定下来;宏观情景实验室已用信息截点保存这类约束。

看结构,再决定是否差分​

结构含义建模时要判断什么
趋势水平长期上升或下降外推水平还是预测变化?
季节性按固定日历周期重复季度周期是否应取 m=4m=4?
时间依赖不同时点的值有关联过去的值能否帮助预测,误差是否也有关联?
平稳性统计性质随时间保持稳定历史关系能否用于当前训练区间?

FPP 的平稳性与差分章节说明,差分可用于减弱趋势或季节性;有明显季节性时先尝试季节差分,并避免过度差分。伯克利的时间序列课程讲义给出弱平稳的定义:均值和有限方差不随时间变化,协方差只取决于间隔;它允许时间依赖。

一阶差分是 Δyt=yt−yt−1\Delta y_t=y_t-y_{t-1},季节差分是 Δmyt=yt−yt−m\Delta_m y_t=y_t-y_{t-m}。后面的假设销量中,y1=100y_1=100、y2=120y_2=120、y5=110y_5=110,所以 Δy2=20\Delta y_2=20,Δ4y5=10\Delta_4 y_5=10。两者分别问“比上一季度增加多少”和“比去年同季度增加多少”。Holt–Winters 等模型直接处理趋势与季节性,无须先差分;训练区间里看起来平稳,也不能保证下一段仍保持同样关系。

从可用的滞后值建立基准​

FPP 的简单预测方法给出两个实用基准:朴素预测重复最后一个观测值;季节朴素预测重复最近一次同季节的值。令 tt 为预测截点,hh 为预测步长,并假定截至 tt 的观测已经可用;季节基准还要求至少有一个完整周期,即 t≥mt\ge m:

y^t+h∣tnaive=yt,y^t+h∣tseasonal=yt+h−m,1≤h≤m.\hat y_{t+h\mid t}^{\mathrm{naive}}=y_t,\qquad \hat y_{t+h\mid t}^{\mathrm{seasonal}}=y_{t+h-m},\quad 1\le h\le m.

超过一个季节周期时,季节朴素预测继续重复最后一个完整周期,不能读取截点后的真实值。这里取季度数据、m=4m=4、h=1,2h=1,2。

特征可以包括截点前的销量、最近四季度均值,以及预测季度的日历信息。若把训练行按目标时点 t+ht+h 编号,滞后 kk 的销量是 yt+h−ky_{t+h-k};在立即发布的假设下,只有 k≥hk\ge h 才不越过截点。有发布延迟时还要查可用时间,单纯移动一行并不够。

生成历史训练行时,要重建该行原预测时刻的输入;在当前截点拟合时,只用已经公布的标签。均值填补、标准化、特征筛选及差分方案都只能在当前训练区间拟合。FPP 的回归预测章节区分事前可知的输入与事后才观测到的输入:未来的日历或预先已知的排班可以使用,未来实际汇率、天气观测和截点后才发布的修订值不能当作已知输入;截至截点已经发布的修订值可以使用。还原水平时,一阶差分预测加回上期水平,季节差分预测加回 mm 期前的水平。从截点时或更早的已知水平出发,若还原过程需要截点后的水平,就用此前得到的预测值;还原后再与水平基准比较。

让预测截点向前移动​

FPP 的时间序列交叉验证要求每次预测只用测试时点之前的观测,并可按多步预测评估。这里还要加上前面的发布约束。先留出最后一段,之前的截点用于比较模型;随着时间向前走,过去的验证观测可以在发布后进入后续训练。

下面是一个假设的季度销量序列,单位相同,每季结束时立即公布且不修订:

季度Q1Q2Q3Q4Q5Q6Q7Q8Q9Q10Q11Q12Q13Q14
销量1001209013011013299143121145109157133160
截点可用训练区间一步目标两步目标用途
Q8 结束Q1–Q8Q9Q10验证
Q9 结束Q1–Q9Q10Q11验证
Q10 结束Q1–Q10Q11Q12验证
Q12 结束Q1–Q12Q13Q14锁定模型后的最终测试

这是扩展窗口:保留全部已可用历史。固定长度的滚动窗口则只保留最近 WW 个观测;WW、最短训练长度和重训频率都应在验证阶段选择。这里从两个完整季节周期开始训练,验证阶段只选能够同时评估两步、且不触碰 Q13 的截点。

除了两个基准,再加入一个可拟合的候选 seasonal_drift:用训练区间内同季度的年度增量均值 dtd_t,给季节朴素预测加一次增量。这个候选假定各季度共享同一个绝对年度增量,只用于本例的一步与两步预测。下面的 fit 同时准备三个候选的状态,输入必须是按季度顺序排列、等间隔、完整且有限的数值,并满足 t>mt>m,才能至少算出一个年度增量。forecast 只接受列出的模型名和 1≤h≤m1\le h\le m 的整数步长。

dt=1t−m∑i=m+1t(yi−yi−m),t>m,y^t+h∣tseasonal_drift=yt+h−m+dt,1≤h≤m.d_t=\frac{1}{t-m}\sum_{i=m+1}^{t}(y_i-y_{i-m}),\quad t>m,\qquad \hat y_{t+h\mid t}^{\mathrm{seasonal\_drift}}=y_{t+h-m}+d_t,\quad 1\le h\le m.

在 Q8,四个年度增量为 10、12、9、13,均值为 11;所以预测 Q9 为 110+11=121110+11=121,Q10 为 132+11=143132+11=143。在 Q10,重新拟合得到 d10=68/6≈11.333d_{10}=68/6\approx11.333。代码每个截点重新拟合,只在预测完成后读取目标值来评分;选择规则是两个步长等权的 MAE。

from math import sqrt
from statistics import mean

# Synthetic quarterly values; Q13-Q14 are reserved for the final test.
y = [100, 120, 90, 130, 110, 132, 99, 143,
121, 145, 109, 157, 133, 160]
m, H, dev_end = 4, 2, 12
names = ("naive", "seasonal", "seasonal_drift")

def fit(train):
if len(train) <= m:
raise ValueError("fit requires more than m observations")
change = mean(train[i] - train[i-m] for i in range(m, len(train)))
return train[-1], train[-m:], change

def forecast(state, name, h):
last, season, change = state
if name not in names:
raise ValueError("unknown model name")
if not isinstance(h, int) or not 1 <= h <= m:
raise ValueError("h must be an integer between 1 and m")
if name == "naive":
return last
return season[h-1] + (change if name == "seasonal_drift" else 0)

errors = {(name, h): [] for name in names for h in range(1, H+1)}
print("origin h actual naive seasonal seasonal_drift")
for t in (8, 9, 10):
assert t + H <= dev_end
state = fit(y[:t])
for h in range(1, H+1):
predictions = [forecast(state, name, h) for name in names]
actual = y[t+h-1] # Read the target only to score the forecasts.
print(t, h, actual, *(f"{p:.3f}" for p in predictions))
for name, p in zip(names, predictions):
errors[name, h].append(actual - p)

print("model h MAE RMSE")
for name in names:
for h in range(1, H+1):
e = errors[name, h]
print(name, h, f"{mean(abs(v) for v in e):.3f}",
f"{sqrt(mean(v*v for v in e)):.3f}")

# Selection rule: MAE with equal weights for the two horizons.
chosen = min(names, key=lambda name: mean(
abs(v) for h in range(1, H+1) for v in errors[name, h]))
print("selected", chosen)
state = fit(y[:dev_end])
print("final_origin h actual forecast error")
for h in range(1, H+1):
p = forecast(state, chosen, h)
actual = y[dev_end+h-1]
print(dev_end, h, actual, f"{p:.3f}", f"{actual-p:.3f}")

输出:

origin h actual naive seasonal seasonal_drift
8 1 121 143.000 110.000 121.000
8 2 145 143.000 132.000 143.000
9 1 145 121.000 132.000 143.000
9 2 109 121.000 99.000 110.000
10 1 109 145.000 99.000 110.333
10 2 157 145.000 143.000 154.333
model h MAE RMSE
naive 1 27.333 28.024
naive 2 8.667 9.866
seasonal 1 11.333 11.402
seasonal 2 12.333 12.450
seasonal_drift 1 1.111 1.388
seasonal_drift 2 1.889 2.009
selected seasonal_drift
final_origin h actual forecast error
12 1 133 132.500 0.500
12 2 160 156.500 3.500

按步长解释误差​

FPP 的点预测误差度量区分训练残差与真正的样本外预测误差,并给出 MAE、RMSE 和百分比误差的定义。对固定的 hh,令 nhn_h 为该步长的预测数:

et,h=yt+h−y^t+h∣t,MAEh=1nh∑t∣et,h∣,RMSEh=1nh∑tet,h2.e_{t,h}=y_{t+h}-\hat y_{t+h\mid t},\qquad \mathrm{MAE}_h=\frac{1}{n_h}\sum_t|e_{t,h}|,\qquad \mathrm{RMSE}_h=\sqrt{\frac{1}{n_h}\sum_t e_{t,h}^2}.

MAE 的单位与销量相同;RMSE 对大误差更敏感。实际值为零时 MAPE 无定义,接近零时会不稳定。比较不同单位的序列可考虑 MASE,其缩放分母必须来自训练区间;季节差分全为零时该分母为零,不能计算。

本例中 seasonal_drift 的一步误差为 0,2,−4/30,2,-4/3,MAE 为 10/9≈1.11110/9\approx1.111;两步误差为 2,−1,8/32,-1,8/3,MAE 为 17/9≈1.88917/9\approx1.889。等权平均为 1.500,比朴素模型的 18.000 和季节朴素模型的 11.833 小。先按步长看结果,再按实际业务权重汇总,才能避免把远期预测的失败藏在一个平均数里。

这些误差也不能假定为六次独立试验。Q10、Q11 各被不同截点预测过。更直接的推导是:假设 yt=yt−1+εty_t=y_{t-1}+\varepsilon_t,增量相互独立、均值为零、方差满足 0<σ2<∞0<\sigma^2<\infty。两步朴素预测的误差为 et,2=εt+1+εt+2e_{t,2}=\varepsilon_{t+1}+\varepsilon_{t+2},下一截点的误差为 et+1,2=εt+2+εt+3e_{t+1,2}=\varepsilon_{t+2}+\varepsilon_{t+3}。两者共享一个增量,协方差为 σ2\sigma^2、各自方差为 2σ22\sigma^2,相关系数为 1/21/2。

因此,比较模型时应在相同截点、相同步长上比较损失差,并让不确定性估计考虑时间依赖。仅仅减少截点重叠,仍不能保证真实数据的误差独立。本例只有三个验证截点,可以演示计算,无法据此给出可靠的显著性结论。

把预测评估接到交易回测​

预测误差低,交易规则也可能亏损。回测还要定义信号产生时间、首次可成交时间、持仓、换手和成本;若收盘后才形成信号,就不能假定已按同一个收盘价成交。历史股票池也要包括当时可选但后来退市的标的;只使用今天仍存续的名单会产生幸存者偏差。

假设一次现金买入再卖出的交易,按成本前参考价格计算,买入名义金额为 100,000 货币单位,卖出名义金额为 100,120,成本前盈利为 120。成本模型把每边手续费、价差和滑点合计设为对应名义金额的 8 个基点。1 个基点为 0.01%,所以买入成本为 100000×0.0008=80100000\times0.0008=80,卖出成本为 100120×0.0008=80.096100120\times0.0008=80.096,往返共 160.096。净盈利就是 120−160.096=−40.096120-160.096=-40.096。这里的费用是例子设定的数值;借券、融资或市场冲击若适用于策略,也要进入成本模型。

重复挑模型、窗口或交易阈值,会让验证期逐渐参与策略设计。Bailey 等人的《回测过拟合的概率》分析这种选择效应,并指出单次留出评估不能衡量之前大量试验带来的偏差。因此要保留全部候选和失败试验,比较成本后的结果,并让所有选择在最终测试之前结束。

留出一段真正未用于选择的时期​

最终测试开始前锁定特征、窗口、预测步长、损失权重、重训规则,以及交易与费用规则。上面的程序在验证后选定 seasonal_drift,再用 Q1–Q12 拟合,年度增量均值为 11.5。Q13、Q14 的预测分别为 132.5、156.5,误差为 0.5、3.5;最终 MAE 为 2.000,RMSE 为 2.500。这里只对选中的模型评分。

真实的最终测试可以按预先锁定的规则继续滚动重训,并在数据到达后使用更早的测试期观测;更新训练数据不等于重新选择规则。应按固定步长报告误差,另列日期区间、可用版本和成本后的交易结果。若看过最终结果后修改模型,这段就已成为新的验证数据,需要另外一段未参与选择的时期。对于大家早已熟知的市场历史,“未用于选择”还要求策略设计没有借用对那段行情的记忆;前瞻记录的新时期能更清楚地满足这个条件。

回到量化金融的模型问题,先核对当时可得的数据,再问模型能否稳定优于同信息、同步长的基准,最后问这种改善能否在可成交的价格和完整成本下保留下来。

探索关联打开关联网络