Week 14:时间序列验证、rolling window 与数据泄露检查

发布于 2026-07-26 14:00 4782 字 24 min read

Week 14:时间序列验证、rolling window 与数据泄露检查。fish-first 终端教学,面向 CachyOS、VS Code、uv 和 Python 学习路线。
Oh My Pi / weekly tutorial / week14-time-series-validation
miku@cachyos:~/Code/python-learning$ omp teach week14-time-series-validation --fish-first --step-by-step
source622 行教学文档
weekWeek 14
shellfish-first 命令版
backlink20 周计划

Week 14:时间序列验证、rolling window 与数据泄露检查

返回总计划:USTC 统计 + AI / 量化 20 周成长计划

Week 14 的核心目标是:不要在时间序列任务里偷看未来。 预测模型不一定要复杂,但验证方式必须正确。对量化和金融时间序列来说,一个看起来很高的分数,经常只是数据泄露造成的幻觉。

本文命令默认使用 fish shell,虚拟环境激活命令为:

ompfish
source .venv/bin/activate.fish

0. 本周详细教学:语法、规范、验收

本节不是追加在尾部的复习,而是本周正文的入口。先读这里,再做后面的命令和项目。

0.1 本周真正要学会什么

维度要求
知识点rolling/expanding validation、naive baseline、防泄漏
代码语法能从空文件写出本周核心脚本,而不是只复制运行
程序规范函数拆分、路径清楚、输入输出明确、错误能解释
交付物src/time_series_validation.py
验收方式从 fish 终端运行命令,得到可复查的文件或指标

0.2 代码语法精讲

下面的代码不是最终答案,而是本周必须理解的最小骨架:

omppython
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 本周和主线的连接

1. 本周学习目标

完成本周后,你应该能做到:

  1. 用时间顺序切分 train / validation / test。
  2. 解释为什么时间序列不能随机打乱。
  3. 实现 naive baseline、rolling window validation、expanding window validation。
  4. 比较线性模型、随机森林和简单基准,而不是只看一个模型。
  5. 检查标准化、rolling 特征、标签构造中的常见泄露。

本周建议交付物:

ompprompt
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 虚拟环境。

进入学习目录并创建项目:

ompfish
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 的模拟价格数据,可以复制过来:

ompfish
cp ../week13-time-series-basics/data/prices.csv data/prices.csv

如果没有,创建 src/make_sample_prices.py

omppython
from pathlib import Path

import 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”)

运行:

ompfish
python src/make_sample_prices.py

检查:

ompfish
python -c "import pandas as pd; df = pd.read_csv('data/prices.csv'); print(df.head()); print(df.tail()); print(df.shape)"

4. 明确预测任务

本周不要一上来就“预测价格”。先定义一个清楚、可验证的小任务:

ompprompt
输入:截至今天已经知道的收益率、滚动均值、滚动波动率
目标:预测明天的日收益率方向
标签:明天收益率是否大于 0

这意味着:

  • t 天的特征只能使用第 t 天及以前的数据。
  • t 天的标签是第 t+1 天的方向。
  • 训练集必须早于验证集,验证集必须早于测试集。

创建 src/time_series_validation.py,先写读取和特征构造:

omppython
from pathlib import Path

import 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. 不要随机打乱时间序列

很多机器学习教程会写:随机划分训练集和测试集。时间序列不能这样做,因为未来样本会混入训练集。

错误思路:

ompprompt
随机抽 80% 日期训练,剩下 20% 日期测试

这会导致:

  • 训练集中可能包含 2024 年数据。
  • 测试集中可能包含 2022 年数据。
  • 模型相当于在预测过去时已经见过未来市场环境。

正确思路:

ompprompt
早期数据:训练
中间数据:验证
后期数据:测试

在脚本中加入时间切分函数:

omppython
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 可以是:明天方向等于今天方向。

omppython
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-learnPipeline 可以减少误用:

omppython
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 的直觉:每次只用最近一段历史训练,然后预测之后一小段。

ompprompt
第 1 次:用 0-199 天训练,预测 200-219 天
第 2 次:用 20-219 天训练,预测 220-239 天
第 3 次:用 40-239 天训练,预测 240-259 天

加入函数:

omppython
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 的直觉:训练集从最早日期开始,随着时间不断扩大。

ompprompt
第 1 次:用 0-199 天训练,预测 200-219 天
第 2 次:用 0-219 天训练,预测 220-239 天
第 3 次:用 0-239 天训练,预测 240-259 天

加入函数:

omppython
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 和模型

加入主程序:

omppython
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(&quot;rolling scores&quot;)
print(rolling_scores)
print(&quot;expanding scores&quot;)
print(expanding_scores)

fig, ax = plt.subplots(figsize=(10, 4))
rolling_scores[&quot;accuracy&quot;].plot(ax=ax, marker=&quot;o&quot;, label=&quot;rolling&quot;)
expanding_scores[&quot;accuracy&quot;].plot(ax=ax, marker=&quot;o&quot;, label=&quot;expanding&quot;)
ax.axhline(0.5, color=&quot;black&quot;, linewidth=1, linestyle=&quot;--&quot;)
ax.set_title(&quot;Validation Accuracy by Window&quot;)
ax.set_xlabel(&quot;window number&quot;)
ax.set_ylabel(&quot;accuracy&quot;)
ax.legend()
fig.tight_layout()
fig.savefig(FIGURE_DIR / &quot;rolling_validation_scores.png&quot;, dpi=150)
plt.close(fig)

if name == “main”: main()

运行:

ompfish
python src/time_series_validation.py
xdg-open figures/split.png
xdg-open figures/rolling_validation_scores.png

如果结果不稳定,这是正常现象。金融预测本来就难。你的任务不是强行调参到高分,而是学会诚实验证。

11. 数据泄露检查清单

写报告前逐项检查。

11.1 时间切分

错误:随机打乱。

正确:

omppython
train = data.iloc[:train_end]
valid = data.iloc[train_end:valid_end]
test = data.iloc[valid_end:]

检查问题:

  • 训练集最后一天是否早于验证集第一天?
  • 验证集最后一天是否早于测试集第一天?
  • 图上能否看出三段按时间顺序排列?

11.2 标签构造

预测明天方向可以使用:

omppython
out["target_up_next_day"] = (out["return_1d"].shift(-1) > 0).astype(int)

但不能把 return_1d.shift(-1) 放进特征列。

11.3 rolling 特征

当日做决策时,rolling() 默认包含当前行。是否允许取决于你的交易设定:

  • 如果你在收盘后计算信号,明天开盘或收盘执行,可以使用当天收盘后才知道的数据。
  • 如果你在当天收盘前就要交易,就不能使用当天收盘价。

初学时建议写清楚假设:

ompprompt
本项目假设在 t 日收盘后计算信号,并在 t+1 日持有,因此 t 日及以前的数据可用。

11.4 标准化

错误:全数据先标准化,再切分。

正确:用 Pipeline,让 scaler 在训练集上 fit。

11.5 调参

如果你反复看测试集表现再改模型,测试集也被你“污染”了。正确流程:

ompprompt
train:训练模型
validation:比较模型和调参
test:最后只评估一次

12. 报告模板

创建报告:

ompfish
code reports/week14_forecast_report.md

建议结构:

ompprompt
# 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. 文件布局检查

执行:

ompfish
pwd
ls
ls data src reports figures

至少应有:

ompprompt
data/prices.csv
src/time_series_validation.py
figures/split.png
figures/rolling_validation_scores.png
reports/week14_forecast_report.md

14. 练习

  1. 把预测目标改成“未来 5 日收益率是否大于 0”。注意标签和特征时间关系。
  2. train_size 从 180 改成 120、240,观察 rolling validation 是否变化。
  3. 添加一个新特征:过去 10 日收益率 return_10d
  4. 比较 logistic regression 和 random forest,写出哪个更稳定,而不是只写哪个最高。
  5. 故意写一个泄露特征 future_return = return_1d.shift(-1),看分数是否异常变高,然后删掉它并在报告中解释为什么不能用。
  6. random_state 改掉,观察随机森林结果是否明显变化。

15. 验收检查

在项目根目录执行:

ompfish
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

喜欢的话,留下你的评论吧~