Week 14:时间序列验证、rolling window 与数据泄露检查
Week 14 的核心目标是:不要在时间序列任务里偷看未来。 预测模型不一定要复杂,但验证方式必须正确。对量化和金融时间序列来说,一个看起来很高的分数,经常只是数据泄露造成的幻觉。
本文命令默认使用 fish shell,虚拟环境激活命令为:
source .venv/bin/activate.fish
0. 本周详细教学:语法、规范、验收
本节不是追加在尾部的复习,而是本周正文的入口。先读这里,再做后面的命令和项目。
0.1 本周真正要学会什么
| 维度 | 要求 |
|---|---|
| 知识点 | rolling/expanding validation、naive baseline、防泄漏 |
| 代码语法 | 能从空文件写出本周核心脚本,而不是只复制运行 |
| 程序规范 | 函数拆分、路径清楚、输入输出明确、错误能解释 |
| 交付物 | src/time_series_validation.py |
| 验收方式 | 从 fish 终端运行命令,得到可复查的文件或指标 |
0.2 代码语法精讲
下面的代码不是最终答案,而是本周必须理解的最小骨架:
def rolling_splits(n, train_size, test_size):
start = 0
while start + train_size + test_size <= n:
train = range(start, start + train_size)
test = range(start + train_size, start + train_size + test_size)
yield list(train), list(test)
start += test_size
读代码时按四步检查:输入从哪里来;中间变量的类型和 shape 是什么;函数或脚本输出什么;哪些错误应该显式报出来。
0.3 本周程序规范
- 所有路径用相对路径或 `pathlib.Path`,不要写死 `/home/miku/...`。
- 核心逻辑进 `src/`,notebook 只做探索和解释。
- 每个脚本能从 fish 终端运行,并在 README 写出命令。
- 输出必须落盘到 `reports/`、`figures/` 或 `outputs/`,不能只在屏幕上看。
0.4 本周练习分层
| 层级 | 任务 | 不合格表现 | 合格验收 |
|---|---|---|---|
| 最小练习 | 手写上面的最小骨架 | 只在 notebook 里运行 | 终端运行成功 |
| 标准练习 | 把逻辑拆成函数/模块 | 一个大脚本从头写到尾 | 至少 2 个函数,职责清楚 |
| 项目练习 | 生成本周交付物 src/time_series_validation.py | 只有屏幕输出 | 文件落盘,可复查 |
| 复盘练习 | 写 3 个错误和修复 | 只写“已解决” | 写清报错、原因、修复、预防 |
0.5 本周和主线的连接
- 回到总计划:USTC AI / Quant 练习手册
- 查详细练习索引:技术练习详解
- 查质量评分:最终质量门槛
1. 本周学习目标
完成本周后,你应该能做到:
- 用时间顺序切分 train / validation / test。
- 解释为什么时间序列不能随机打乱。
- 实现 naive baseline、rolling window validation、expanding window validation。
- 比较线性模型、随机森林和简单基准,而不是只看一个模型。
- 检查标准化、rolling 特征、标签构造中的常见泄露。
本周建议交付物:
week14-time-series-validation/
├── data/
│ └── prices.csv
├── figures/
│ ├── split.png
│ └── rolling_validation_scores.png
├── reports/
│ └── week14_forecast_report.md
├── src/
│ └── time_series_validation.py
├── pyproject.toml
└── README.md
2. 前置条件
你需要已经完成或理解 Week 13:
- 价格和收益率的区别。
pct_change()、rolling()、shift()的基本用法。- 为什么收益率比价格更适合建模。
- Python 项目的基本结构和 uv 虚拟环境。
进入学习目录并创建项目:
mkdir -p ~/Code/ustc-stat-ai-quant
cd ~/Code/ustc-stat-ai-quant
mkdir week14-time-series-validation
cd week14-time-series-validation
uv init
uv venv
source .venv/bin/activate.fish
uv add pandas numpy matplotlib scikit-learn
mkdir -p data src reports figures
code .
解释:
| 命令 | 作用 |
|---|---|
uv add scikit-learn |
本周会用线性模型、随机森林和指标 |
mkdir -p data src reports figures |
保持数据、代码、报告、图片分离 |
source .venv/bin/activate.fish |
使用 fish 语法激活环境 |
3. 准备数据
如果你已经有 Week 13 的模拟价格数据,可以复制过来:
cp ../week13-time-series-basics/data/prices.csv data/prices.csv
如果没有,创建 src/make_sample_prices.py:
from pathlib import Pathimport numpy as np import pandas as pd
rng = np.random.default_rng(2026) dates = pd.bdate_range(“2021-01-04”, periods=520)
market_noise = rng.normal(0.0002, 0.011, size=len(dates)) slow_cycle = 0.002 * np.sin(np.arange(len(dates)) / 30) log_returns = market_noise + slow_cycle price = 100 * np.exp(np.cumsum(log_returns))
Path(“data”).mkdir(exist_ok=True) pd.DataFrame({“date”: dates, “close”: price}).to_csv(“data/prices.csv”, index=False) print(“saved data/prices.csv”)
运行:
python src/make_sample_prices.py
检查:
python -c "import pandas as pd; df = pd.read_csv('data/prices.csv'); print(df.head()); print(df.tail()); print(df.shape)"
4. 明确预测任务
本周不要一上来就“预测价格”。先定义一个清楚、可验证的小任务:
输入:截至今天已经知道的收益率、滚动均值、滚动波动率
目标:预测明天的日收益率方向
标签:明天收益率是否大于 0
这意味着:
- 第
t天的特征只能使用第t天及以前的数据。 - 第
t天的标签是第t+1天的方向。 - 训练集必须早于验证集,验证集必须早于测试集。
创建 src/time_series_validation.py,先写读取和特征构造:
from pathlib import Pathimport matplotlib.pyplot as plt import numpy as np import pandas as pd from sklearn.ensemble import RandomForestClassifier from sklearn.linear_model import LogisticRegression from sklearn.metrics import accuracy_score, precision_score from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler
FIGURE_DIR = Path(“figures”) FIGURE_DIR.mkdir(exist_ok=True)
def load_prices(path: str = “data/prices.csv”) -> pd.DataFrame: df = pd.read_csv(path, parse_dates=[“date”]) df = df.sort_values(“date”).set_index(“date”) return df
def make_features(df: pd.DataFrame) -> pd.DataFrame: out = df.copy() out[“return_1d”] = out[“close”].pct_change() out[“return_5d”] = out[“close”].pct_change(5) out[“rolling_mean_5”] = out[“return_1d”].rolling(5).mean() out[“rolling_vol_5”] = out[“return_1d”].rolling(5).std() out[“rolling_mean_20”] = out[“return_1d”].rolling(20).mean() out[“rolling_vol_20”] = out[“return_1d”].rolling(20).std()
plain # 预测明天方向:明天收益率大于 0 记为 1,否则 0 out[“target_up_next_day”] = (out[“return_1d”].shift(-1) > 0).astype(int) out = out.dropna() return out
关键点:shift(-1) 只用于构造标签,不可以把未来收益率当成特征。
5. 不要随机打乱时间序列
很多机器学习教程会写:随机划分训练集和测试集。时间序列不能这样做,因为未来样本会混入训练集。
错误思路:
随机抽 80% 日期训练,剩下 20% 日期测试
这会导致:
- 训练集中可能包含 2024 年数据。
- 测试集中可能包含 2022 年数据。
- 模型相当于在预测过去时已经见过未来市场环境。
正确思路:
早期数据:训练
中间数据:验证
后期数据:测试
在脚本中加入时间切分函数:
def chronological_split(data: pd.DataFrame) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]: n = len(data) train_end = int(n * 0.6) valid_end = int(n * 0.8) train = data.iloc[:train_end] valid = data.iloc[train_end:valid_end] test = data.iloc[valid_end:] return train, valid, test
def plot_split(train: pd.DataFrame, valid: pd.DataFrame, test: pd.DataFrame) -> None: fig, ax = plt.subplots(figsize=(10, 4)) train[“close”].plot(ax=ax, label=“train”) valid[“close”].plot(ax=ax, label=“validation”) test[“close”].plot(ax=ax, label=“test”) ax.set_title(“Chronological Split”) ax.legend() fig.tight_layout() fig.savefig(FIGURE_DIR / “split.png”, dpi=150) plt.close(fig)
6. 建立 naive baseline
在预测方向任务中,最简单的 baseline 可以是:明天方向等于今天方向。
def evaluate_naive(data: pd.DataFrame) -> dict[str, float]:
y_true = data["target_up_next_day"]
y_pred = (data["return_1d"] > 0).astype(int)
return {
"accuracy": accuracy_score(y_true, y_pred),
"precision": precision_score(y_true, y_pred, zero_division=0),
}
为什么 baseline 重要?
- 如果复杂模型打不过 naive baseline,说明模型暂时没价值。
- 如果只比 50% 高一点点,不一定有交易价值,因为交易成本会吃掉优势。
- 量化里“略高于随机”也可能是过拟合,需要滚动验证和样本外验证。
7. 用 Pipeline 避免标准化泄露
标准化常见泄露:先用全数据计算均值和标准差,再切分训练测试。这样测试集的信息已经进入训练流程。
正确方式:标准化器只在训练集 fit,然后对验证集或测试集 transform。
scikit-learn 的 Pipeline 可以减少误用:
FEATURE_COLUMNS = [ "return_1d", "return_5d", "rolling_mean_5", "rolling_vol_5", "rolling_mean_20", "rolling_vol_20", ] TARGET_COLUMN = "target_up_next_day"def evaluate_model(train: pd.DataFrame, test: pd.DataFrame, model_name: str) -> dict[str, float | str]: x_train = train[FEATURE_COLUMNS] y_train = train[TARGET_COLUMN] x_test = test[FEATURE_COLUMNS] y_test = test[TARGET_COLUMN]
plain if model_name == “logistic”: model = Pipeline( steps=[ (“scaler”, StandardScaler()), (“model”, LogisticRegression(max_iter=1000)), ] ) elif model_name == “random_forest”: model = RandomForestClassifier( n_estimators=200, max_depth=3, random_state=42, ) else: raise ValueError(f”unknown model: {model_name}”)
plain model.fit(x_train, y_train) pred = model.predict(x_test) return { “model”: model_name, “accuracy”: accuracy_score(y_test, pred), “precision”: precision_score(y_test, pred, zero_division=0), }
注意:随机森林通常不需要标准化;逻辑回归需要。
8. rolling window validation
rolling window 的直觉:每次只用最近一段历史训练,然后预测之后一小段。
第 1 次:用 0-199 天训练,预测 200-219 天
第 2 次:用 20-219 天训练,预测 220-239 天
第 3 次:用 40-239 天训练,预测 240-259 天
加入函数:
def rolling_window_validation(
data: pd.DataFrame,
train_size: int,
test_size: int,
step_size: int,
model_name: str,
) -> pd.DataFrame:
rows = []
start = 0
while start + train_size + test_size <= len(data):
train = data.iloc[start : start + train_size]
test = data.iloc[start + train_size : start + train_size + test_size]
score = evaluate_model(train, test, model_name)
rows.append(
{
"train_start": train.index[0],
"train_end": train.index[-1],
"test_start": test.index[0],
"test_end": test.index[-1],
**score,
}
)
start += step_size
return pd.DataFrame(rows)
优点:更接近真实策略的滚动更新。缺点:早期历史会被丢弃,样本利用率较低。
9. expanding window validation
expanding window 的直觉:训练集从最早日期开始,随着时间不断扩大。
第 1 次:用 0-199 天训练,预测 200-219 天
第 2 次:用 0-219 天训练,预测 220-239 天
第 3 次:用 0-239 天训练,预测 240-259 天
加入函数:
def expanding_window_validation(
data: pd.DataFrame,
initial_train_size: int,
test_size: int,
step_size: int,
model_name: str,
) -> pd.DataFrame:
rows = []
train_end = initial_train_size
while train_end + test_size <= len(data):
train = data.iloc[:train_end]
test = data.iloc[train_end : train_end + test_size]
score = evaluate_model(train, test, model_name)
rows.append(
{
"train_start": train.index[0],
"train_end": train.index[-1],
"test_start": test.index[0],
"test_end": test.index[-1],
**score,
}
)
train_end += step_size
return pd.DataFrame(rows)
优点:历史数据利用更多。缺点:如果市场结构变化很大,太久远的数据可能拖累模型。
10. 主程序:比较 baseline 和模型
加入主程序:
def main() -> None: prices = load_prices() data = make_features(prices) train, valid, test = chronological_split(data) plot_split(train, valid, test)plain print(“naive validation:”, evaluate_naive(valid)) print(“naive test:”, evaluate_naive(test))
plain for model_name in [“logistic”, “random_forest”]: print(“single split”, evaluate_model(train, valid, model_name))
plain rolling_scores = rolling_window_validation( data=data, train_size=180, test_size=20, step_size=20, model_name=“logistic”, ) expanding_scores = expanding_window_validation( data=data, initial_train_size=180, test_size=20, step_size=20, model_name=“logistic”, )
print("rolling scores") print(rolling_scores) print("expanding scores") print(expanding_scores) fig, ax = plt.subplots(figsize=(10, 4)) rolling_scores["accuracy"].plot(ax=ax, marker="o", label="rolling") expanding_scores["accuracy"].plot(ax=ax, marker="o", label="expanding") ax.axhline(0.5, color="black", linewidth=1, linestyle="--") ax.set_title("Validation Accuracy by Window") ax.set_xlabel("window number") ax.set_ylabel("accuracy") ax.legend() fig.tight_layout() fig.savefig(FIGURE_DIR / "rolling_validation_scores.png", dpi=150) plt.close(fig)
if name == “main”: main()
运行:
python src/time_series_validation.py
xdg-open figures/split.png
xdg-open figures/rolling_validation_scores.png
如果结果不稳定,这是正常现象。金融预测本来就难。你的任务不是强行调参到高分,而是学会诚实验证。
11. 数据泄露检查清单
写报告前逐项检查。
11.1 时间切分
错误:随机打乱。
正确:
train = data.iloc[:train_end]
valid = data.iloc[train_end:valid_end]
test = data.iloc[valid_end:]
检查问题:
- 训练集最后一天是否早于验证集第一天?
- 验证集最后一天是否早于测试集第一天?
- 图上能否看出三段按时间顺序排列?
11.2 标签构造
预测明天方向可以使用:
out["target_up_next_day"] = (out["return_1d"].shift(-1) > 0).astype(int)
但不能把 return_1d.shift(-1) 放进特征列。
11.3 rolling 特征
当日做决策时,rolling() 默认包含当前行。是否允许取决于你的交易设定:
- 如果你在收盘后计算信号,明天开盘或收盘执行,可以使用当天收盘后才知道的数据。
- 如果你在当天收盘前就要交易,就不能使用当天收盘价。
初学时建议写清楚假设:
本项目假设在 t 日收盘后计算信号,并在 t+1 日持有,因此 t 日及以前的数据可用。
11.4 标准化
错误:全数据先标准化,再切分。
正确:用 Pipeline,让 scaler 在训练集上 fit。
11.5 调参
如果你反复看测试集表现再改模型,测试集也被你“污染”了。正确流程:
train:训练模型
validation:比较模型和调参
test:最后只评估一次
12. 报告模板
创建报告:
code reports/week14_forecast_report.md
建议结构:
# Week 14 Forecast Report
Task
- 预测目标:明天收益率方向
- 特征:过去收益率、rolling mean、rolling volatility
Data split
- train 日期范围:
- validation 日期范围:
- test 日期范围:
Baseline
- naive baseline accuracy:
- naive baseline precision:
Models
- logistic regression:
- random forest:
Rolling validation
- rolling window 设置:
- expanding window 设置:
- 哪种结果更稳定?
Leakage checks
- 是否随机打乱:否
- 标准化是否只在训练集 fit:是
- 特征是否使用未来收益率:否
- 测试集是否只最终评估:是
Conclusion
- 模型是否明显优于 baseline?
- 结果是否稳定?
下一步如果要做回测,还缺什么?
13. 文件布局检查
执行:
pwd
ls
ls data src reports figures
至少应有:
data/prices.csv
src/time_series_validation.py
figures/split.png
figures/rolling_validation_scores.png
reports/week14_forecast_report.md
14. 练习
- 把预测目标改成“未来 5 日收益率是否大于 0”。注意标签和特征时间关系。
- 把
train_size从 180 改成 120、240,观察 rolling validation 是否变化。 - 添加一个新特征:过去 10 日收益率
return_10d。 - 比较 logistic regression 和 random forest,写出哪个更稳定,而不是只写哪个最高。
- 故意写一个泄露特征
future_return = return_1d.shift(-1),看分数是否异常变高,然后删掉它并在报告中解释为什么不能用。 - 把
random_state改掉,观察随机森林结果是否明显变化。
15. 验收检查
在项目根目录执行:
source .venv/bin/activate.fish
python src/time_series_validation.py
python -c "from pathlib import Path; required = ['data/prices.csv', 'src/time_series_validation.py', 'figures/split.png', 'figures/rolling_validation_scores.png', 'reports/week14_forecast_report.md']; missing = [p for p in required if not Path(p).exists()]; print('missing:', missing); raise SystemExit(1 if missing else 0)"
通过标准:
- 不使用随机打乱切分。
- 至少实现 naive baseline 和一个机器学习模型。
- 至少有 rolling 或 expanding window 的多窗口结果。
- 报告中有明确的数据泄露检查。
- 能解释为什么测试集不能反复用于调参。
16. 常见错误
错误 1:直接使用 train_test_split 默认随机划分
时间序列任务不要默认随机。你可以不用 train_test_split,直接用 iloc 按时间切。
错误 2:模型分数高得不正常
优先检查:
- 特征里是否有未来收益率?
- 是否全数据标准化后再切分?
- 是否把测试集用于多次调参?
- 标签是否和特征同一天,导致预测任务变成已知当天涨跌?
错误 3:rolling window 前面缺失太多
窗口越大,需要的历史越多。可以减少窗口,或接受前几行因信息不足而被删除。
错误 4:precision 报 warning
如果模型全预测为 0,就没有正类预测。示例代码用 zero_division=0 避免中断,但报告里要说明模型可能没有学到有效信号。
错误 5:只报告最高分
只写最高分没有意义。你需要报告窗口间稳定性、是否优于 baseline、是否可能泄露。
17. 下一步
进入 Week 15:mini quant backtest:动量规则、交易成本与风险指标。下一周会把本周的“预测/信号”变成持仓和策略收益,并加入交易成本。
plain
If you enjoyed this, leave a comment~