单元 08:参数、样本与过拟合
单元 07 说明了不能看完结果再挑最好的一段。本单元面对一个更隐蔽的问题:即使回测代码完全正确,只要尝试了足够多的参数,也很容易在历史数据中找到一个冠军。
我们会扫描 2~60 日移动平均窗口,但只允许样本内数据参与选择。窗口锁定后再揭晓样本外表现,并用邻近参数、成本变化、切分日期和滚动验证检查结论。最终目标不是找到更高的收益数字,而是判断“历史冠军”究竟有多少证据支持。
本单元目标
完成本单元后,你将能够:
- 按时间顺序划分样本内和样本外数据;
- 确保参数选择过程看不到样本外结果;
- 解释为什么尝试次数越多,偶然冠军越容易出现;
- 检查冠军附近的参数是否表现一致;
- 使用成本和切分日期情景检查结论的稳健性;
- 完成一次扩展窗口滚动验证;
- 区分训练集、验证集与只能拆封一次的测试集。
运行本单元只需要:
uv run python examples/unit08/analyze_overfitting.py前置条件
- 已完成单元 07;
- 能解释未来函数、选择偏差、交易成本和最大回撤;
- 已执行
uv sync --locked; - 后续命令均在项目根目录运行。
场景:为什么换一条更长的虚构价格路径
前几个单元使用的 DEMO_ETF 只有 10 个观测,适合逐行核对收益、持仓和成本,却不足以承担参数扫描。把 10 个观测再切成样本内外,只会得到几乎完全由起止日期决定的数字。
本单元因此在代码中生成 360 个虚构交易时点。日期由 pandas 的工作日序列生成,不代表任何真实市场日历;价格不对应真实证券,也不能用于投资分析。随机种子固定为 20240720,所以每次运行都会得到完全相同的路径。
这条路径被故意设计成前后两种状态。设第
前 240 个观测中,正收益后更容易继续出现正收益,负收益后也更容易继续出现负收益。它用于模拟一段具有持续性的行情。
后 120 个观测改为:
正负收益更容易交替,用来模拟均值回归(Mean Reversion)特征。这里:
:第 个虚构交易时点的简单收益率,无单位; :前一个时点的简单收益率,无单位; :人为设置的单期漂移,等于 0.015%; 与 :控制前后收益关系的系数,无单位; :均值为 0、标准差为 0.35% 的固定种子随机扰动。
这是教学装置,不是市场模型
真实市场不会在某一天整齐地从“持续”切换为“均值回归”,收益也不满足这样简单且稳定的公式。我们故意制造明显的结构变化(Structural Break),是为了让样本内有效、样本外失效的过程容易观察。
在扫描参数前冻结时间边界
程序先确定切分点,再计算任何参数排名:
| 区间 | 日期 | 观测数 | 用途 |
|---|---|---|---|
| 样本内(In-sample) | 2023-01-02 至 2023-12-01 | 240 | 扫描并选择窗口 |
| 样本外(Out-of-sample,OOS) | 2023-12-04 至 2024-05-17 | 120 | 参数锁定后的最终检查 |
时间序列不能像普通表格那样随机打乱后切分。随机切分会把较晚行情放进训练数据、较早行情放进测试数据,既破坏时间顺序,也可能让相邻观测的信息跨越边界。
我们仍然沿用单元 05 的回测约束:收盘价高于尾随移动平均线时目标持仓为 1,否则为 0;目标持仓延迟一个时点后才成为实际持仓;单边成本率为 0.1%。
参数选择只能看到样本内
样本内扫描与样本外揭晓被拆成两个函数:
in_sample_scan = scan_in_sample(prices)
selected_window = choose_window(in_sample_scan)
# 窗口已经锁定,之后才计算样本外结果
scan = reveal_out_of_sample(prices, in_sample_scan)scan_in_sample() 返回的表中根本没有 out_of_sample_return 列。choose_window() 只按样本内总收益降序排列;若收益相同,再选择较短窗口。这样,“没有使用样本外数据”不只是一句约定,而是由接口和数据列共同约束。
程序扫描 2~60 日窗口,共尝试 59 个候选参数。样本内冠军是 3 日窗口:
| SMA 窗口 | 样本内收益 | 样本外收益 |
|---|---|---|
| 2 | 41.6685% | -22.3586% |
| 3 | 44.2920% | -18.4671% |
| 4 | 40.2028% | -17.4496% |
| 5 | 39.0380% | -14.5730% |
| 6 | 34.9480% | -15.5001% |
| 7 | 35.8754% | -12.4972% |
3 日窗口在样本内增长 44.2920%,锁定后却在样本外亏损 18.4671%。附近窗口也全部由正转负,所以问题不是“恰好选错了 3,换成 4 就好”,而是这整类短周期趋势规则依赖了前一段行情的持续性。
过拟合不一定长得像孤立尖峰
过拟合(Overfitting)是规则过度适应已经见过的数据,把偶然噪声或只属于某段历史的关系当成可重复规律。它在新数据中通常明显衰减或失效。
有时过拟合会表现为参数图上的一个孤立尖峰:冠军远高于相邻参数,稍微改动窗口便崩溃。本例却不是这样。2~7 日窗口在样本内形成一片较高区域,说明策略确实捕捉到了人为制造的持续性;真正的问题是这种持续性在样本外发生了变化。
这一区别很重要:
- 孤立冠军可能说明参数在追逐噪声;
- 一片平滑的好参数通常比孤立冠军更可信,但仍可能只适合某种市场状态;
- 参数稳定性是支持证据,不是永不过期的保证。
数据窥探(Data Snooping)指反复查看同一份数据,并根据结果不断修改标的、指标、参数或区间。尝试 59 个窗口后,冠军不仅包含策略信息,也包含“59 次机会中最幸运的一次”。如果再根据样本外图改选 17 日窗口,那么这 120 个观测也已经参与开发,不再是未见数据。
样本外只能拆封一次
查看样本外结果后,如果据此改规则或参数,这段数据就从测试集变成了开发资料。下一次诚实评价需要更新、更晚且未参与任何选择的数据。
滚动验证模拟多次向前走
单次切分可能碰巧落在有利或不利位置。滚动验证(Walk-forward Validation)沿时间向前重复“用过去选择、用随后一段验证”。本例只在前 240 个样本内观测中进行,最终 120 个观测仍保持封闭。
我们从 120 个训练观测开始,每次增加 40 个观测:
| 轮次 | 训练区间末端 | 随后验证区间 | 选中窗口 | 训练收益 | 验证收益 |
|---|---|---|---|---|---|
| 1 | 2023-06-16 | 2023-06-19 至 2023-08-11 | 3 | 13.1806% | 3.2386% |
| 2 | 2023-08-11 | 2023-08-14 至 2023-10-06 | 3 | 16.8460% | 13.2116% |
| 3 | 2023-10-06 | 2023-10-09 至 2023-12-01 | 3 | 32.2833% | 9.0780% |
三轮验证都为正,而且每轮都选中 3 日窗口。如果此时停下,证据看起来相当一致;最终样本外仍然亏损 18.4671%。原因是三轮滚动验证都位于同一种人为状态中,无法预见之后的结构变化。
滚动验证能够减少结果对单一切分点的依赖,却不能保证未来环境与历史相同。
改变成本和切分日期
稳健性(Robustness)关注结论是否只在一个精确设定下成立。程序固定 3 日窗口,先改变成本:
| 单边成本率 | 样本外收益 |
|---|---|
| 0.05% | -15.6403% |
| 0.10% | -18.4671% |
| 0.20% | -23.8436% |
成本越高,短窗口频繁换手的损失越大。但即使采用更低的 0.05%,样本外结论仍然为负。
程序还把样本外起点分别移动到 2023-11-06、2023-12-04 和 2024-01-01,并在每个新边界之前重新选择参数:
| 样本外起点 | 选中窗口 | 样本外收益 |
|---|---|---|
| 2023-11-06 | 3 | -16.6314% |
| 2023-12-04 | 3 | -18.4671% |
| 2024-01-01 | 3 | -16.4574% |
切分日期变化没有挽救结论。这里的“稳健”不是说策略稳健盈利,而是说“样本外失效”这个判断对几种合理设定都成立。
训练、验证和测试回答不同问题
这三个名称经常被混用,可以先按用途区分:
| 数据 | 回答的问题 | 能否据此改策略 |
|---|---|---|
| 训练集(Training Set) | 候选规则在已知历史中如何表现 | 可以 |
| 验证集(Validation Set) | 哪套规则或参数更值得保留 | 可以,但反复使用也会过拟合 |
| 测试集(Test Set) | 冻结后的研究流程面对未见数据如何表现 | 不可以;修改后需要新的测试集 |
为了保持第一次实践清晰,本例把前 240 个观测统称为样本内开发区间,并在其中用滚动训练与验证检查一致性;最后 120 个观测充当一次性测试集。
“样本内/样本外”描述数据是否参与开发,“训练/验证/测试”更强调数据承担的职责。它们有关联,但不是必须一一对应的同义词。
运行并检查产物
执行:
uv run python examples/unit08/analyze_overfitting.py终端核心输出为:
样本内区间: 2023-01-02 至 2023-12-01
样本外区间: 2023-12-04 至 2024-05-17
样本内选中窗口: 3
选中窗口样本内收益: 44.2920%
选中窗口样本外收益: -18.4671%
滚动验证:
fold selected_window train_return validation_return
1 3 13.1806% 3.2386%
2 3 16.8460% 13.2116%
3 3 32.2833% 9.0780%程序会生成:
outputs/unit08/synthetic_prices.csv:固定种子生成的虚构价格;outputs/unit08/parameter_scan.csv:59 个窗口的样本内外对照;outputs/unit08/selected_backtest.csv:3 日窗口的完整逐日回测;outputs/unit08/walk_forward.csv:三轮滚动训练与验证结果;outputs/unit08/robustness.csv:成本与切分日期情景;outputs/unit08/overfitting_diagnostics.png:参数、净值和滚动验证诊断图。
诊断图上方比较每个窗口的样本内外收益,中间展示选中策略跨越结构变化后的净值,底部展示三轮滚动验证。它们共同说明:过去的证据可以相当一致,未来状态仍可能不同。
常见陷阱
用样本外修好样本外
看到 -18.4671% 后改用样本外亏损较少的窗口,再把它称为“样本外结果”,相当于看过答案后改卷。应把这次结果记录下来,并等待新的未见数据验证修改后的版本。
只报告冠军,不报告尝试范围
“3 日窗口收益 44.2920%”遗漏了我们还尝试过另外 58 个窗口。参数范围、选择指标、并列处理和尝试次数都应与冠军一起记录。
把滚动验证当成实盘保证
滚动验证仍然只使用历史数据。本例三轮验证全部为正,却没有覆盖后来的均值回归状态。它能提供更多时间切片证据,不能穷尽未来市场状态。
不断移动切分点直到结论变好
敏感性检查的目的是观察结论怎样变化,不是从多个边界中再挑一个冠军。应预先写下要检查的边界,并同时展示全部情景。
认为合成数据证明了真实策略
本例的盈亏由人为公式和固定随机种子共同产生,只证明研究流程会怎样响应这条路径。它没有证明 3 日移动平均策略适用于任何真实 ETF。
验证结果
完成标准:
- 360 个虚构观测在固定随机种子下可以重复生成;
- 前 240 个观测用于样本内选择,后 120 个观测只在窗口锁定后揭晓;
- 2~60 日共 59 个候选窗口均参与扫描,样本内选中 3 日窗口;
- 3 日窗口样本内收益为 44.2920%,样本外收益为 -18.4671%;
- 三轮滚动验证没有越过最终样本外边界;
- 成本和切分日期变化没有把样本外失效改写成正收益;
- 能说明样本外数据被查看后为什么不能继续作为新策略的未见测试集。
小结
本单元完成了一次严格按时间顺序进行的参数实验:
- 在查看结果前冻结 240/120 的样本边界;
- 只在样本内扫描 2~60 日移动平均窗口;
- 锁定 3 日窗口后,才揭晓 -18.4671% 的样本外收益;
- 检查相邻参数,发现整个短窗口区域都在结构变化后失效;
- 通过滚动验证、成本和切分日期情景补充稳健性证据;
- 明确查看测试集后不能再把它当作未见数据。
一次样本外成功仍不能保证实盘盈利,一次失败也不自动证明所有相关思路永远无效。样本外评价真正提供的是更诚实的反证机会:让策略面对没有参与选择的数据。
练习
基础练习:缩小候选范围
把候选窗口限制为 2~10 日,比较减少尝试次数是否改变冠军和样本外结论。
进阶练习:改变成本与验证长度
将单边成本率改为 0,观察短窗口为何受益最多,并说明这种假设是否合理。再把滚动验证的验证长度从 40 改为 20,比较轮次数量与每轮证据强度。
延伸练习:更换虚构路径
保持最终 120 个观测不变,更换随机种子,记录哪些结论稳定、哪些数字随路径变化。